Skip to content

Commit 987ce7f

Browse files
authored
Merge pull request #235 from PyAutoLabs/claude/dashboard-task-copy-button-jw6cet
intake dashboard: emit a one-tap-copy HTML twin alongside the markdown page
2 parents 5384a20 + e9aa975 commit 987ce7f

3 files changed

Lines changed: 290 additions & 20 deletions

File tree

agents/conductors/intake/_intake.py

Lines changed: 236 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,7 @@ def _count(key):
483483

484484
return {
485485
"generated": _dt.date.today().isoformat(),
486+
"home": _mind_home(mind),
486487
"total": len(records),
487488
"issued_count": len(in_flight),
488489
"by_work_type": _count("work_type"),
@@ -501,6 +502,28 @@ def _cell(value: str) -> str:
501502
return str(value).replace("|", "\\|")
502503

503504

505+
def _mind_home(mind: Path) -> str:
506+
"""The Mind repo's GitHub home (`https://github.com/<org>/PyAutoMind`).
507+
508+
Read from `repos.yaml` — the single source of repo identity — so no
509+
tenant org name is baked into organ code (the tenant-firewall rule: a
510+
fork's Mind carries its own org there). Empty string when unknown; the
511+
renderers then degrade to relative links and skip the Pages pointer.
512+
"""
513+
f = mind / "repos.yaml"
514+
if not f.is_file():
515+
return ""
516+
m = re.search(r"^\s{2}PyAutoMind:\s*\n\s+github:\s*([^\s#]+)",
517+
f.read_text(encoding="utf-8", errors="replace"), re.M)
518+
return f"https://github.com/{m.group(1)}" if m else ""
519+
520+
521+
def _pages_url(home: str) -> str:
522+
"""The GitHub Pages site URL for a repo home, `''` when underivable."""
523+
m = re.match(r"https://github\.com/([^/]+)/([^/]+)$", home)
524+
return f"https://{m.group(1).lower()}.github.io/{m.group(2)}/" if m else ""
525+
526+
504527
def _summary_label(value: str) -> str:
505528
"""Task text rendered inside a `<summary>` — HTML, not markdown.
506529
@@ -567,6 +590,19 @@ def _items(chunks: list) -> list:
567590
return out[:-1] if out else []
568591

569592

593+
def _registry_payload(e: dict, key: str, verb: str) -> str:
594+
"""The copy payload for a parked/planned registry row.
595+
596+
A registry row may name its prompt file; without one there is no
597+
start_dev target, so route the slug as free prose instead.
598+
"""
599+
prompt = (e["prompt"].split() or [""])[0]
600+
if prompt.endswith(".md"):
601+
return f"/start_dev {prompt}"
602+
return (f"/route {verb} the {key} PyAutoMind task "
603+
f"{e['slug']} — its record is in {key}.md")
604+
605+
570606
def _bullet(r: dict) -> str:
571607
"""One backlog prompt as a row — the phone-readable unit of this page.
572608
@@ -611,6 +647,12 @@ def render_dashboard(c: dict) -> str:
611647
"to reveal that command, copy it, and paste it into a Claude Code "
612648
"chat to route Claude straight to the task.",
613649
"",
650+
]
651+
pages = _pages_url(c.get("home", ""))
652+
if pages:
653+
L += [f"Reading on a phone? The [one-tap copy version]({pages}) puts "
654+
"the command on your clipboard with a single tap of 📋.", ""]
655+
L += [
614656
"Tasks only — the organism's health lives with the Heart (`/health`), "
615657
"not here.",
616658
"",
@@ -674,13 +716,7 @@ def render_dashboard(c: dict) -> str:
674716
head += f" — <a href=\"{e['issue']}\">issue #{e['issue_no']}</a>"
675717
if e["status"]:
676718
head += f" — {_summary_label(_clip(e['status']))}"
677-
# A registry row may name its prompt file; without one there is no
678-
# start_dev target, so route the slug as free prose instead.
679-
prompt = (e["prompt"].split() or [""])[0]
680-
payload = (f"/start_dev {prompt}" if prompt.endswith(".md") else
681-
f"/route {verb} the {key} PyAutoMind task "
682-
f"{e['slug']} — its record is in {key}.md")
683-
items.append(_task_row(head, payload))
719+
items.append(_task_row(head, _registry_payload(e, key, verb)))
684720
L += _items(items) or ["- _(none)_"]
685721
L += ["", "</details>", ""]
686722

@@ -704,6 +740,176 @@ def render_dashboard(c: dict) -> str:
704740
return "\n".join(L).rstrip("\n") + "\n"
705741

706742

743+
_HTML_CSS = """\
744+
:root{color-scheme:light dark;--bg:#fff;--fg:#1f2328;--muted:#59636e;
745+
--line:#d1d9e0;--btn:#f6f8fa;--ok:#1a7f37;--accent:#0969da}
746+
@media(prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#f0f6fc;
747+
--muted:#9198a1;--line:#3d444d;--btn:#151b23;--ok:#3fb950;--accent:#4493f8}}
748+
*{box-sizing:border-box}
749+
body{margin:0 auto;max-width:44rem;padding:1rem 1rem 4rem;background:var(--bg);
750+
color:var(--fg);font:16px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",
751+
Helvetica,Arial,sans-serif}
752+
h1{font-size:1.35rem;margin:.4rem 0}
753+
h2{font-size:1.15rem;margin-top:2rem;border-bottom:1px solid var(--line);
754+
padding-bottom:.3rem}
755+
h3{font-size:1rem;margin:1.2rem 0 .2rem}
756+
a{color:var(--accent);text-decoration:none}
757+
a:hover{text-decoration:underline}
758+
.muted{color:var(--muted)}
759+
.facets{color:var(--muted);font-size:.85em}
760+
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.92em;
761+
background:var(--btn);padding:.1em .3em;border-radius:4px}
762+
.task{display:flex;gap:.6rem;align-items:flex-start;padding:.45rem 0;
763+
border-bottom:1px solid var(--line)}
764+
.task p{margin:.25rem 0 0;flex:1;overflow-wrap:anywhere}
765+
button.copy{flex:0 0 auto;width:2.6rem;height:2.6rem;font-size:1.1rem;
766+
border:1px solid var(--line);border-radius:8px;background:var(--btn);
767+
cursor:pointer;color:var(--fg)}
768+
button.copy.ok{color:var(--ok);border-color:var(--ok)}
769+
details{margin:.5rem 0}
770+
summary{cursor:pointer;font-weight:600;padding:.4rem 0}
771+
"""
772+
773+
# One tap on 📋 → the command is on the clipboard; the button flashes ✓. The
774+
# textarea path covers browsers without the async clipboard API.
775+
_HTML_JS = """\
776+
async function copyCmd(b){
777+
const cmd=b.dataset.cmd;
778+
try{await navigator.clipboard.writeText(cmd);}
779+
catch(e){const t=document.createElement("textarea");t.value=cmd;
780+
document.body.appendChild(t);t.select();document.execCommand("copy");
781+
t.remove();}
782+
b.textContent="\\u2713";b.classList.add("ok");
783+
setTimeout(()=>{b.textContent="\\ud83d\\udccb";b.classList.remove("ok");},1200);}
784+
document.addEventListener("click",e=>{
785+
const b=e.target.closest("button.copy");if(b)copyCmd(b);});
786+
"""
787+
788+
789+
def _attr(value: str) -> str:
790+
"""Escape a string for a double-quoted HTML attribute."""
791+
return (str(value).replace("&", "&amp;").replace('"', "&quot;")
792+
.replace("<", "&lt;").replace(">", "&gt;"))
793+
794+
795+
def _html_task(text_html: str, payload: str) -> str:
796+
"""One task row on the HTML page: a real copy button, then the text."""
797+
return (f'<div class="task"><button class="copy" '
798+
f'data-cmd="{_attr(payload)}" aria-label="Copy the Claude '
799+
f'command">📋</button><p>{text_html}</p></div>')
800+
801+
802+
def render_dashboard_html(c: dict) -> str:
803+
"""Render the census as `dashboard.html` — the one-tap-copy twin.
804+
805+
Same content as `render_dashboard`, different constraint: this page is
806+
served by GitHub Pages (see PyAutoMind's `pages_dashboard.yml`), so it may
807+
carry the JavaScript that GitHub's markdown rendering strips — a real
808+
copy-to-clipboard button per task, which the markdown page cannot have (in
809+
the GitHub mobile app fenced blocks offer no copy affordance at all). Links
810+
are absolute (from the census `home`) because Pages serves this file away
811+
from the repo blobs; with no home they fall back to relative paths. The
812+
Hygiene section stays markdown-only — this page exists to pick from, not
813+
to audit. Self-contained by design: inline CSS/JS, no external assets.
814+
"""
815+
records = sorted(c["records"], key=_pick_key)
816+
home = c.get("home", "")
817+
blob = f"{home}/blob/main/" if home else ""
818+
819+
def link(path, text_html):
820+
return f'<a href="{_attr(blob + path)}">{text_html}</a>'
821+
822+
def record_row(r):
823+
text = link(r["path"], _summary_label(r["title"]))
824+
facets = " · ".join(_summary_label(x) for x in
825+
(r["target"], r["difficulty"],
826+
r["autonomy"], r["priority"]) if x != "-")
827+
if facets:
828+
text += f' — <span class="facets">{facets}</span>'
829+
return _html_task(text, f"/start_dev {r['path']}")
830+
831+
H = [
832+
"<!doctype html>",
833+
'<html lang="en">',
834+
"<head>",
835+
'<meta charset="utf-8">',
836+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
837+
"<title>PyAutoMind task dashboard</title>",
838+
f"<!-- generated by `pyauto-brain intake dashboard --apply` on "
839+
f"{c['generated']} — regenerate, do not hand-edit -->",
840+
f"<style>{_HTML_CSS}</style>",
841+
"</head>",
842+
"<body>",
843+
"<h1>📋 PyAutoMind task dashboard</h1>",
844+
"<p>Every task the Mind is holding. Tap a task's 📋 and its "
845+
"<code>/start_dev</code> command is on your clipboard — paste it into "
846+
"a Claude Code chat to route Claude straight to that task.</p>",
847+
f'<p class="muted">In flight {c["issued_count"]} · '
848+
f'Parked {len(c["parked"])} · Planned {len(c["planned"])} · '
849+
f'Backlog {c["total"]}'
850+
+ (f' · {link("dashboard.md", "markdown version")}' if home else "")
851+
+ "</p>",
852+
"<h2>Start here</h2>",
853+
]
854+
855+
high = [r for r in records if r["priority"] == "high"]
856+
quick = [r for r in records
857+
if r["difficulty"] == "small" and r["autonomy"] == "safe"]
858+
for title, note, rows in (
859+
("Highest priority", "filed as high", high),
860+
("Quick wins", "small enough, and safe enough to run unattended", quick),
861+
):
862+
shown = rows[:PICK_LIST_MAX]
863+
more = (f" — showing {len(shown)} of {len(rows)}"
864+
if len(rows) > len(shown) else "")
865+
H += [f'<h3>{title} <span class="facets">({note}){more}</span></h3>']
866+
H += [record_row(r) for r in shown] or ['<p class="muted">(none right now)</p>']
867+
868+
H += ["<h2>In flight</h2>",
869+
'<p class="muted">Issued — each has an open GitHub issue and '
870+
"usually a branch.</p>"]
871+
for r in c["in_flight"]:
872+
text = link(r["path"], _summary_label(r["title"]))
873+
if r["issue_no"]:
874+
text += f' — <a href="{_attr(r["issue"])}">issue #{r["issue_no"]}</a>'
875+
if r["status"]:
876+
text += (f' — <span class="facets">'
877+
f'{_summary_label(_clip(r["status"]))}</span>')
878+
H.append(_html_task(text, f"/start_dev {r['path']}"))
879+
if not c["in_flight"]:
880+
H.append('<p class="muted">(nothing in flight)</p>')
881+
882+
for key, heading, verb in (("parked", "Parked", "resume"),
883+
("planned", "Planned", "start")):
884+
rows = c[key]
885+
H += [f"<h2>{heading}</h2>", "<details>",
886+
f"<summary>{len(rows)} task(s)</summary>"]
887+
for e in rows:
888+
text = f"<b>{_summary_label(e['slug'])}</b>"
889+
if e["issue_no"]:
890+
text += (f' — <a href="{_attr(e["issue"])}">'
891+
f'issue #{e["issue_no"]}</a>')
892+
if e["status"]:
893+
text += (f' — <span class="facets">'
894+
f'{_summary_label(_clip(e["status"]))}</span>')
895+
H.append(_html_task(text, _registry_payload(e, key, verb)))
896+
if not rows:
897+
H.append('<p class="muted">(none)</p>')
898+
H.append("</details>")
899+
900+
H += ["<h2>Backlog</h2>",
901+
f'<p class="muted">{c["total"]} filed prompts, not started — '
902+
"sorted most-pickable first (priority, then size).</p>"]
903+
for wt, n in c["by_work_type"].items():
904+
rows = [r for r in records if r["work_type"] == wt]
905+
H += ["<details>", f"<summary>{wt}{n}</summary>"]
906+
H += [record_row(r) for r in rows]
907+
H += ["</details>"]
908+
909+
H += [f"<script>{_HTML_JS}</script>", "</body>", "</html>"]
910+
return "\n".join(H) + "\n"
911+
912+
707913
def _dashboard_body(page: str) -> str:
708914
"""The page minus its generation stamp — what `--check` compares.
709915
@@ -1454,7 +1660,8 @@ def main(argv=None):
14541660
sub.add_parser("census", help="inventory all filed prompts (always read-only)")
14551661

14561662
db = sub.add_parser("dashboard", help="render the census as the Mind task "
1457-
"page; --apply writes dashboard.md")
1663+
"page; --apply writes dashboard.md "
1664+
"+ its one-tap-copy dashboard.html")
14581665
db.add_argument("--check", action="store_true",
14591666
help="exit 1 if the committed dashboard.md has drifted from "
14601667
"what this run renders (the generation date is ignored, "
@@ -1508,29 +1715,39 @@ def main(argv=None):
15081715

15091716
if a.cmd == "dashboard":
15101717
c = census(mind)
1511-
page = render_dashboard(c)
1718+
# Two renderings of one census: the markdown page GitHub renders in
1719+
# the repo, and its one-tap-copy HTML twin GitHub Pages serves.
1720+
pages = {"dashboard.md": render_dashboard(c),
1721+
"dashboard.html": render_dashboard_html(c)}
15121722
if a.check:
1513-
target = mind / "dashboard.md"
1514-
on_disk = target.read_text(encoding="utf-8") if target.is_file() else ""
1515-
if _dashboard_body(on_disk) == _dashboard_body(page):
1516-
print("dashboard.md is current")
1723+
stale = []
1724+
for name, want in pages.items():
1725+
target = mind / name
1726+
on_disk = (target.read_text(encoding="utf-8")
1727+
if target.is_file() else "")
1728+
if _dashboard_body(on_disk) != _dashboard_body(want):
1729+
stale.append(name)
1730+
if not stale:
1731+
print("dashboard.md + dashboard.html are current")
15171732
return 0
1518-
print("dashboard.md is stale — regenerate with "
1733+
print(f"{' + '.join(stale)} stale — regenerate with "
15191734
"`pyauto-brain intake dashboard --apply`", file=sys.stderr)
15201735
return 1
15211736
written = None
15221737
if a.apply:
1523-
(mind / "dashboard.md").write_text(page, encoding="utf-8")
1524-
written = "dashboard.md"
1738+
for name, want in pages.items():
1739+
(mind / name).write_text(want, encoding="utf-8")
1740+
written = " + ".join(pages)
15251741
if a.as_json:
15261742
summary = {k: v for k, v in c.items() if k != "records"}
1527-
print(json.dumps({"census": summary, "page": page, "written": written},
1528-
indent=2))
1743+
print(json.dumps({"census": summary, "page": pages["dashboard.md"],
1744+
"html": pages["dashboard.html"],
1745+
"written": written}, indent=2))
15291746
elif written:
15301747
print(f"Wrote: {written} ({c['total']} prompts, "
15311748
f"{len(c['hygiene'])} hygiene flag(s))")
15321749
else:
1533-
print(page, end="")
1750+
print(pages["dashboard.md"], end="")
15341751
return 0
15351752

15361753
if a.cmd == "classify":

skills/intake/intake.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ dev workflow (issue, branch, plan). Do not bypass the Brain.
3535
Every task renders as one collapsed row whose leading 📋 toggle hides a
3636
code fence (GitHub's copy button) holding the `/start_dev <prompt-path>`
3737
message that routes Claude to that task — the phone path from the page
38-
into a session.
38+
into a session. `--apply` also writes `dashboard.html`, the one-tap-copy
39+
twin GitHub Pages serves (real clipboard buttons; PyAutoMind's
40+
`pages_dashboard.yml` deploys it), with links derived from `repos.yaml`.
3941
Dry-run prints it, `--apply` writes it (commit via `prompt_sync_push`),
4042
`--check` exits 1 if the committed page has drifted — PyAutoMind's
4143
`dashboard_refresh.yml` self-heals that on pushes to main, so regenerate it

tests/test_intake_dashboard.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,57 @@ def test_copy_details_never_swallow_the_next_row(tmp_path):
235235
assert "</details>\n-" not in page
236236

237237

238+
# --------------------------------------------------------------------------- #
239+
# the HTML twin: real one-tap copy buttons, served by GitHub Pages
240+
# --------------------------------------------------------------------------- #
241+
# The org is fictional for the same tenant-firewall reason as the issue URLs:
242+
# renderers read the GitHub home from repos.yaml, never from organ code.
243+
REPOS_YAML = "repos:\n PyAutoMind:\n github: ExampleOrg/PyAutoMind\n"
244+
245+
246+
def _html(mind: Path) -> str:
247+
return _intake.render_dashboard_html(_intake.census(mind))
248+
249+
250+
def test_html_task_has_a_copy_button_holding_the_command(tmp_path):
251+
mind = _mind(tmp_path, drafts={"bug/widgets/one.md": _prompt("Bug one")})
252+
html = _html(mind)
253+
assert ('<button class="copy" data-cmd="/start_dev '
254+
'draft/bug/widgets/one.md"') in html
255+
assert "navigator.clipboard.writeText" in html, \
256+
"the page must carry its own clipboard script — that is its point"
257+
258+
259+
def test_html_links_use_the_github_home_from_repos_yaml(tmp_path):
260+
"""Pages serves the file away from the repo blobs, so links must be
261+
absolute — and the org comes from repos.yaml, never organ code."""
262+
mind = _mind(tmp_path, drafts={"bug/widgets/one.md": _prompt("Bug one")},
263+
registries={"repos.yaml": REPOS_YAML})
264+
html = _html(mind)
265+
assert ('<a href="https://github.com/ExampleOrg/PyAutoMind/blob/main/'
266+
'draft/bug/widgets/one.md">Bug one</a>') in html
267+
268+
269+
def test_markdown_page_points_at_the_pages_twin_only_when_home_known(tmp_path):
270+
mind = _mind(tmp_path, drafts={"bug/widgets/one.md": _prompt("Bug one")},
271+
registries={"repos.yaml": REPOS_YAML})
272+
assert "https://exampleorg.github.io/PyAutoMind/" in _page(mind)
273+
bare = _mind(tmp_path / "bare", drafts={"bug/widgets/x.md": _prompt("X")})
274+
assert "github.io" not in _page(bare), \
275+
"a Mind without repos.yaml must not invent a Pages link"
276+
277+
278+
def test_check_covers_the_html_twin(tmp_path, capsys):
279+
mind = _mind(tmp_path, drafts={"bug/widgets/one.md": _prompt("Bug one")})
280+
assert _intake.main(["--mind", str(mind), "--apply", "dashboard"]) == 0
281+
html = mind / "dashboard.html"
282+
html.write_text(
283+
html.read_text(encoding="utf-8").replace("Bug one", "Bug gone"),
284+
encoding="utf-8")
285+
assert _intake.main(["--mind", str(mind), "dashboard", "--check"]) == 1
286+
assert "dashboard.html" in capsys.readouterr().err
287+
288+
238289
# --------------------------------------------------------------------------- #
239290
# rendering safety
240291
# --------------------------------------------------------------------------- #

0 commit comments

Comments
 (0)