39 KiB
Feedback Button — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add a floating feedback button to Peregrine that lets beta testers file Forgejo issues directly from the UI, with optional PII-masked diagnostic data and screenshot attachments.
Architecture: Pure Python backend in scripts/feedback_api.py (no Streamlit dep, wrappable in FastAPI later) + thin Streamlit shell in app/feedback.py. Floating button uses CSS position: fixed targeting via aria-label. Screenshots via server-side Playwright (capture) and st.file_uploader (upload).
Tech Stack: Python requests, re, playwright (optional), Streamlit 1.54 (@st.dialog), Forgejo REST API v1.
Task 1: Project setup — env config + Playwright dep
Files:
- Modify:
.env.example - Modify:
requirements.txt
Step 1: Add env vars to .env.example
Open .env.example and add after the existing API keys block:
# Feedback button — Forgejo issue filing
FORGEJO_API_TOKEN=
FORGEJO_REPO=pyr0ball/peregrine
FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
# GITHUB_TOKEN= # future — enable when public mirror is active
# GITHUB_REPO= # future
Step 2: Add playwright to requirements.txt
Add to requirements.txt:
playwright>=1.40
Step 3: Install playwright and its browsers
conda run -n job-seeker pip install playwright
conda run -n job-seeker playwright install chromium --with-deps
Expected: chromium browser downloaded to playwright cache.
Step 4: Add FORGEJO_API_TOKEN to your local .env
Open .env and add:
FORGEJO_API_TOKEN=1c39fc7aa05f9a8c421c492413e4be183c4ff670
FORGEJO_REPO=pyr0ball/peregrine
FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
Step 5: Commit
git add requirements.txt .env.example
git commit -m "chore: add playwright dep and Forgejo env config for feedback button"
Task 2: Backend — PII masking + context collection
Files:
- Create:
scripts/feedback_api.py - Create:
tests/test_feedback_api.py
Step 1: Write failing tests
Create tests/test_feedback_api.py:
"""Tests for the feedback API backend."""
import pytest
from unittest.mock import patch, MagicMock
from pathlib import Path
# ── mask_pii ──────────────────────────────────────────────────────────────────
def test_mask_pii_email():
from scripts.feedback_api import mask_pii
assert mask_pii("contact foo@bar.com please") == "contact [email redacted] please"
def test_mask_pii_phone_dashes():
from scripts.feedback_api import mask_pii
assert mask_pii("call 555-123-4567 now") == "call [phone redacted] now"
def test_mask_pii_phone_parens():
from scripts.feedback_api import mask_pii
assert mask_pii("(555) 867-5309") == "[phone redacted]"
def test_mask_pii_clean_text():
from scripts.feedback_api import mask_pii
assert mask_pii("no sensitive data here") == "no sensitive data here"
def test_mask_pii_multiple_emails():
from scripts.feedback_api import mask_pii
result = mask_pii("a@b.com and c@d.com")
assert result == "[email redacted] and [email redacted]"
# ── collect_context ───────────────────────────────────────────────────────────
def test_collect_context_required_keys():
from scripts.feedback_api import collect_context
ctx = collect_context("Home")
for key in ("page", "version", "tier", "llm_backend", "os", "timestamp"):
assert key in ctx, f"missing key: {key}"
def test_collect_context_page_value():
from scripts.feedback_api import collect_context
ctx = collect_context("MyPage")
assert ctx["page"] == "MyPage"
def test_collect_context_timestamp_is_utc():
from scripts.feedback_api import collect_context
ctx = collect_context("X")
assert ctx["timestamp"].endswith("Z")
Step 2: Run to verify they fail
conda run -n job-seeker pytest tests/test_feedback_api.py -v 2>&1 | head -30
Expected: ModuleNotFoundError: No module named 'scripts.feedback_api'
Step 3: Create scripts/feedback_api.py with mask_pii and collect_context
"""
Feedback API — pure Python backend, no Streamlit imports.
Called directly from app/feedback.py now; wrappable in a FastAPI route later.
"""
from __future__ import annotations
import os
import platform
import re
import subprocess
from datetime import datetime, timezone
from pathlib import Path
import requests
import yaml
_ROOT = Path(__file__).parent.parent
_EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")
_PHONE_RE = re.compile(r"(\+?1[\s\-.]?)?\(?\d{3}\)?[\s\-.]?\d{3}[\s\-.]?\d{4}")
def mask_pii(text: str) -> str:
"""Redact email addresses and phone numbers from text."""
text = _EMAIL_RE.sub("[email redacted]", text)
text = _PHONE_RE.sub("[phone redacted]", text)
return text
def collect_context(page: str) -> dict:
"""Collect app context: page, version, tier, LLM backend, OS, timestamp."""
# App version from git
try:
version = subprocess.check_output(
["git", "describe", "--tags", "--always"],
cwd=_ROOT, text=True, timeout=5,
).strip()
except Exception:
version = "dev"
# Tier from user.yaml
tier = "unknown"
try:
user = yaml.safe_load((_ROOT / "config" / "user.yaml").read_text()) or {}
tier = user.get("tier", "unknown")
except Exception:
pass
# LLM backend from llm.yaml
llm_backend = "unknown"
try:
llm = yaml.safe_load((_ROOT / "config" / "llm.yaml").read_text()) or {}
llm_backend = llm.get("provider", "unknown")
except Exception:
pass
return {
"page": page,
"version": version,
"tier": tier,
"llm_backend": llm_backend,
"os": platform.platform(),
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
Step 4: Run tests to verify they pass
conda run -n job-seeker pytest tests/test_feedback_api.py::test_mask_pii_email \
tests/test_feedback_api.py::test_mask_pii_phone_dashes \
tests/test_feedback_api.py::test_mask_pii_phone_parens \
tests/test_feedback_api.py::test_mask_pii_clean_text \
tests/test_feedback_api.py::test_mask_pii_multiple_emails \
tests/test_feedback_api.py::test_collect_context_required_keys \
tests/test_feedback_api.py::test_collect_context_page_value \
tests/test_feedback_api.py::test_collect_context_timestamp_is_utc -v
Expected: 8 PASSED.
Step 5: Commit
git add scripts/feedback_api.py tests/test_feedback_api.py
git commit -m "feat: feedback_api — mask_pii + collect_context"
Task 3: Backend — log + listing collection
Files:
- Modify:
scripts/feedback_api.py - Modify:
tests/test_feedback_api.py
Step 1: Write failing tests
Append to tests/test_feedback_api.py:
# ── collect_logs ──────────────────────────────────────────────────────────────
def test_collect_logs_returns_string(tmp_path):
from scripts.feedback_api import collect_logs
log = tmp_path / ".streamlit.log"
log.write_text("line1\nline2\nline3\n")
result = collect_logs(log_path=log, n=10)
assert isinstance(result, str)
assert "line3" in result
def test_collect_logs_tails_n_lines(tmp_path):
from scripts.feedback_api import collect_logs
log = tmp_path / ".streamlit.log"
log.write_text("\n".join(f"line{i}" for i in range(200)))
result = collect_logs(log_path=log, n=10)
assert "line199" in result
assert "line0" not in result
def test_collect_logs_masks_pii(tmp_path):
from scripts.feedback_api import collect_logs
log = tmp_path / "test.log"
log.write_text("user foo@bar.com connected\n")
result = collect_logs(log_path=log)
assert "foo@bar.com" not in result
assert "[email redacted]" in result
def test_collect_logs_missing_file(tmp_path):
from scripts.feedback_api import collect_logs
result = collect_logs(log_path=tmp_path / "nonexistent.log")
assert "no log file" in result.lower()
# ── collect_listings ──────────────────────────────────────────────────────────
def test_collect_listings_safe_fields_only(tmp_path):
"""Only title, company, url — no cover letters, notes, or emails."""
from scripts.db import init_db, insert_job
from scripts.feedback_api import collect_listings
db = tmp_path / "test.db"
init_db(db)
insert_job(db, {
"title": "CSM", "company": "Acme", "url": "https://example.com/1",
"source": "linkedin", "location": "Remote", "is_remote": True,
"salary": "", "description": "great role", "date_found": "2026-03-01",
})
results = collect_listings(db_path=db, n=5)
assert len(results) == 1
assert set(results[0].keys()) == {"title", "company", "url"}
assert results[0]["title"] == "CSM"
def test_collect_listings_respects_n(tmp_path):
from scripts.db import init_db, insert_job
from scripts.feedback_api import collect_listings
db = tmp_path / "test.db"
init_db(db)
for i in range(10):
insert_job(db, {
"title": f"Job {i}", "company": "Acme", "url": f"https://example.com/{i}",
"source": "linkedin", "location": "Remote", "is_remote": False,
"salary": "", "description": "", "date_found": "2026-03-01",
})
assert len(collect_listings(db_path=db, n=3)) == 3
Step 2: Run to verify they fail
conda run -n job-seeker pytest tests/test_feedback_api.py -k "collect_logs or collect_listings" -v 2>&1 | head -20
Expected: all FAIL with ImportError or similar.
Step 3: Add functions to scripts/feedback_api.py
Append after collect_context:
def collect_logs(n: int = 100, log_path: Path | None = None) -> str:
"""Return last n lines of the Streamlit log, with PII masked."""
path = log_path or (_ROOT / ".streamlit.log")
if not path.exists():
return "(no log file found)"
lines = path.read_text(errors="replace").splitlines()
return mask_pii("\n".join(lines[-n:]))
def collect_listings(db_path: Path | None = None, n: int = 5) -> list[dict]:
"""Return the n most-recent job listings — title, company, url only."""
import sqlite3
from scripts.db import DEFAULT_DB
path = db_path or DEFAULT_DB
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT title, company, url FROM jobs ORDER BY id DESC LIMIT ?", (n,)
).fetchall()
conn.close()
return [{"title": r["title"], "company": r["company"], "url": r["url"]} for r in rows]
Step 4: Run tests to verify they pass
conda run -n job-seeker pytest tests/test_feedback_api.py -k "collect_logs or collect_listings" -v
Expected: 6 PASSED.
Step 5: Commit
git add scripts/feedback_api.py tests/test_feedback_api.py
git commit -m "feat: feedback_api — collect_logs + collect_listings"
Task 4: Backend — issue body builder
Files:
- Modify:
scripts/feedback_api.py - Modify:
tests/test_feedback_api.py
Step 1: Write failing tests
Append to tests/test_feedback_api.py:
# ── build_issue_body ──────────────────────────────────────────────────────────
def test_build_issue_body_contains_description():
from scripts.feedback_api import build_issue_body
form = {"type": "bug", "title": "Test", "description": "it broke", "repro": ""}
ctx = {"page": "Home", "version": "v1.0", "tier": "free",
"llm_backend": "ollama", "os": "Linux", "timestamp": "2026-03-03T00:00:00Z"}
body = build_issue_body(form, ctx, {})
assert "it broke" in body
assert "Home" in body
assert "v1.0" in body
def test_build_issue_body_bug_includes_repro():
from scripts.feedback_api import build_issue_body
form = {"type": "bug", "title": "X", "description": "desc", "repro": "step 1\nstep 2"}
body = build_issue_body(form, {}, {})
assert "step 1" in body
assert "Reproduction" in body
def test_build_issue_body_no_repro_for_feature():
from scripts.feedback_api import build_issue_body
form = {"type": "feature", "title": "X", "description": "add dark mode", "repro": "ignored"}
body = build_issue_body(form, {}, {})
assert "Reproduction" not in body
def test_build_issue_body_logs_in_collapsible():
from scripts.feedback_api import build_issue_body
form = {"type": "other", "title": "X", "description": "Y", "repro": ""}
body = build_issue_body(form, {}, {"logs": "log line 1\nlog line 2"})
assert "<details>" in body
assert "log line 1" in body
def test_build_issue_body_omits_logs_when_not_provided():
from scripts.feedback_api import build_issue_body
form = {"type": "bug", "title": "X", "description": "Y", "repro": ""}
body = build_issue_body(form, {}, {})
assert "<details>" not in body
def test_build_issue_body_submitter_attribution():
from scripts.feedback_api import build_issue_body
form = {"type": "bug", "title": "X", "description": "Y", "repro": ""}
body = build_issue_body(form, {}, {"submitter": "Jane Doe <jane@example.com>"})
assert "Jane Doe" in body
def test_build_issue_body_listings_shown():
from scripts.feedback_api import build_issue_body
form = {"type": "bug", "title": "X", "description": "Y", "repro": ""}
listings = [{"title": "CSM", "company": "Acme", "url": "https://example.com/1"}]
body = build_issue_body(form, {}, {"listings": listings})
assert "CSM" in body
assert "Acme" in body
Step 2: Run to verify they fail
conda run -n job-seeker pytest tests/test_feedback_api.py -k "build_issue_body" -v 2>&1 | head -20
Step 3: Add build_issue_body to scripts/feedback_api.py
Append after collect_listings:
def build_issue_body(form: dict, context: dict, attachments: dict) -> str:
"""Assemble the Forgejo issue markdown body from form data, context, and attachments."""
_TYPE_LABELS = {"bug": "🐛 Bug", "feature": "✨ Feature Request", "other": "💬 Other"}
lines: list[str] = [
f"## {_TYPE_LABELS.get(form.get('type', 'other'), '💬 Other')}",
"",
form.get("description", ""),
"",
]
if form.get("type") == "bug" and form.get("repro"):
lines += ["### Reproduction Steps", "", form["repro"], ""]
if context:
lines += ["### Context", ""]
for k, v in context.items():
lines.append(f"- **{k}:** {v}")
lines.append("")
if attachments.get("logs"):
lines += [
"<details>",
"<summary>App Logs (last 100 lines)</summary>",
"",
"```",
attachments["logs"],
"```",
"</details>",
"",
]
if attachments.get("listings"):
lines += ["### Recent Listings", ""]
for j in attachments["listings"]:
lines.append(f"- [{j['title']} @ {j['company']}]({j['url']})")
lines.append("")
if attachments.get("submitter"):
lines += ["---", f"*Submitted by: {attachments['submitter']}*"]
return "\n".join(lines)
Step 4: Run tests to verify they pass
conda run -n job-seeker pytest tests/test_feedback_api.py -k "build_issue_body" -v
Expected: 7 PASSED.
Step 5: Commit
git add scripts/feedback_api.py tests/test_feedback_api.py
git commit -m "feat: feedback_api — build_issue_body"
Task 5: Backend — Forgejo API client
Files:
- Modify:
scripts/feedback_api.py - Modify:
tests/test_feedback_api.py
Step 1: Write failing tests
Append to tests/test_feedback_api.py:
# ── Forgejo API ───────────────────────────────────────────────────────────────
@patch("scripts.feedback_api.requests.get")
@patch("scripts.feedback_api.requests.post")
def test_ensure_labels_uses_existing(mock_post, mock_get):
from scripts.feedback_api import _ensure_labels
mock_get.return_value.ok = True
mock_get.return_value.json.return_value = [
{"name": "beta-feedback", "id": 1},
{"name": "bug", "id": 2},
]
ids = _ensure_labels(
["beta-feedback", "bug"],
"https://example.com/api/v1", {"Authorization": "token x"}, "owner/repo"
)
assert ids == [1, 2]
mock_post.assert_not_called()
@patch("scripts.feedback_api.requests.get")
@patch("scripts.feedback_api.requests.post")
def test_ensure_labels_creates_missing(mock_post, mock_get):
from scripts.feedback_api import _ensure_labels
mock_get.return_value.ok = True
mock_get.return_value.json.return_value = []
mock_post.return_value.ok = True
mock_post.return_value.json.return_value = {"id": 99}
ids = _ensure_labels(
["needs-triage"],
"https://example.com/api/v1", {"Authorization": "token x"}, "owner/repo"
)
assert 99 in ids
@patch("scripts.feedback_api._ensure_labels", return_value=[1, 2])
@patch("scripts.feedback_api.requests.post")
def test_create_forgejo_issue_success(mock_post, mock_labels, monkeypatch):
from scripts.feedback_api import create_forgejo_issue
monkeypatch.setenv("FORGEJO_API_TOKEN", "testtoken")
monkeypatch.setenv("FORGEJO_REPO", "owner/repo")
monkeypatch.setenv("FORGEJO_API_URL", "https://example.com/api/v1")
mock_post.return_value.status_code = 201
mock_post.return_value.raise_for_status = lambda: None
mock_post.return_value.json.return_value = {"number": 42, "html_url": "https://example.com/issues/42"}
result = create_forgejo_issue("Test issue", "body text", ["beta-feedback", "bug"])
assert result["number"] == 42
assert "42" in result["url"]
@patch("scripts.feedback_api.requests.post")
def test_upload_attachment_returns_url(mock_post, monkeypatch):
from scripts.feedback_api import upload_attachment
monkeypatch.setenv("FORGEJO_API_TOKEN", "testtoken")
monkeypatch.setenv("FORGEJO_REPO", "owner/repo")
monkeypatch.setenv("FORGEJO_API_URL", "https://example.com/api/v1")
mock_post.return_value.status_code = 201
mock_post.return_value.raise_for_status = lambda: None
mock_post.return_value.json.return_value = {
"uuid": "abc", "browser_download_url": "https://example.com/assets/abc"
}
url = upload_attachment(42, b"\x89PNG", "screenshot.png")
assert url == "https://example.com/assets/abc"
Step 2: Run to verify they fail
conda run -n job-seeker pytest tests/test_feedback_api.py -k "label or issue or attach" -v 2>&1 | head -20
Step 3: Add Forgejo API functions to scripts/feedback_api.py
Append after build_issue_body:
def _ensure_labels(
label_names: list[str], base_url: str, headers: dict, repo: str
) -> list[int]:
"""Look up or create Forgejo labels by name. Returns list of IDs."""
_COLORS = {
"beta-feedback": "#0075ca",
"needs-triage": "#e4e669",
"bug": "#d73a4a",
"feature-request": "#a2eeef",
"question": "#d876e3",
}
resp = requests.get(f"{base_url}/repos/{repo}/labels", headers=headers, timeout=10)
existing = {lb["name"]: lb["id"] for lb in resp.json()} if resp.ok else {}
ids: list[int] = []
for name in label_names:
if name in existing:
ids.append(existing[name])
else:
r = requests.post(
f"{base_url}/repos/{repo}/labels",
headers=headers,
json={"name": name, "color": _COLORS.get(name, "#ededed")},
timeout=10,
)
if r.ok:
ids.append(r.json()["id"])
return ids
def create_forgejo_issue(title: str, body: str, labels: list[str]) -> dict:
"""Create a Forgejo issue. Returns {"number": int, "url": str}."""
token = os.environ.get("FORGEJO_API_TOKEN", "")
repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine")
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
label_ids = _ensure_labels(labels, base, headers, repo)
resp = requests.post(
f"{base}/repos/{repo}/issues",
headers=headers,
json={"title": title, "body": body, "labels": label_ids},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
return {"number": data["number"], "url": data["html_url"]}
def upload_attachment(
issue_number: int, image_bytes: bytes, filename: str = "screenshot.png"
) -> str:
"""Upload a screenshot to an existing Forgejo issue. Returns attachment URL."""
token = os.environ.get("FORGEJO_API_TOKEN", "")
repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine")
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
headers = {"Authorization": f"token {token}"}
resp = requests.post(
f"{base}/repos/{repo}/issues/{issue_number}/assets",
headers=headers,
files={"attachment": (filename, image_bytes, "image/png")},
timeout=15,
)
resp.raise_for_status()
return resp.json().get("browser_download_url", "")
Step 4: Run tests to verify they pass
conda run -n job-seeker pytest tests/test_feedback_api.py -k "label or issue or attach" -v
Expected: 4 PASSED.
Step 5: Run full test suite to check for regressions
conda run -n job-seeker pytest tests/test_feedback_api.py -v
Expected: all PASSED.
Step 6: Commit
git add scripts/feedback_api.py tests/test_feedback_api.py
git commit -m "feat: feedback_api — Forgejo label management + issue filing + attachment upload"
Task 6: Backend — server-side screenshot capture
Files:
- Modify:
scripts/feedback_api.py - Modify:
tests/test_feedback_api.py
Step 1: Write failing tests
Append to tests/test_feedback_api.py:
# ── screenshot_page ───────────────────────────────────────────────────────────
def test_screenshot_page_returns_none_without_playwright(monkeypatch):
"""If playwright is not installed, screenshot_page returns None gracefully."""
import builtins
real_import = builtins.__import__
def mock_import(name, *args, **kwargs):
if name == "playwright.sync_api":
raise ImportError("no playwright")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", mock_import)
from scripts.feedback_api import screenshot_page
result = screenshot_page(port=9999)
assert result is None
@patch("scripts.feedback_api.sync_playwright")
def test_screenshot_page_returns_bytes(mock_pw):
"""screenshot_page returns PNG bytes when playwright is available."""
from scripts.feedback_api import screenshot_page
fake_png = b"\x89PNG\r\n\x1a\n"
mock_context = MagicMock()
mock_pw.return_value.__enter__ = lambda s: mock_context
mock_pw.return_value.__exit__ = MagicMock(return_value=False)
mock_browser = mock_context.chromium.launch.return_value
mock_page = mock_browser.new_page.return_value
mock_page.screenshot.return_value = fake_png
result = screenshot_page(port=8502)
assert result == fake_png
Step 2: Run to verify they fail
conda run -n job-seeker pytest tests/test_feedback_api.py -k "screenshot" -v 2>&1 | head -20
Step 3: Add screenshot_page to scripts/feedback_api.py
Append after upload_attachment. Note the try/except ImportError for graceful degradation:
def screenshot_page(port: int | None = None) -> bytes | None:
"""
Capture a screenshot of the running Peregrine UI using Playwright.
Returns PNG bytes, or None if Playwright is not installed.
"""
try:
from playwright.sync_api import sync_playwright
except ImportError:
return None
if port is None:
port = int(os.environ.get("STREAMLIT_PORT", os.environ.get("STREAMLIT_SERVER_PORT", "8502")))
try:
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1280, "height": 800})
page.goto(f"http://localhost:{port}", timeout=10_000)
page.wait_for_load_state("networkidle", timeout=10_000)
png = page.screenshot(full_page=False)
browser.close()
return png
except Exception:
return None
Also add the import at the top of the try block to satisfy the mock test. The import at the function level is correct — do NOT add it to the module level, because we want the graceful degradation path to work.
Step 4: Run tests to verify they pass
conda run -n job-seeker pytest tests/test_feedback_api.py -k "screenshot" -v
Expected: 2 PASSED.
Step 5: Run full backend test suite
conda run -n job-seeker pytest tests/test_feedback_api.py -v
Expected: all PASSED.
Step 6: Commit
git add scripts/feedback_api.py tests/test_feedback_api.py
git commit -m "feat: feedback_api — screenshot_page with Playwright (graceful fallback)"
Task 7: UI — floating button + feedback dialog
Files:
- Create:
app/feedback.py
No pytest tests for Streamlit UI (too brittle for dialogs). Manual verification in Task 8.
Step 1: Create app/feedback.py
"""
Floating feedback button + dialog — thin Streamlit shell.
All business logic lives in scripts/feedback_api.py.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import streamlit as st
# ── CSS: float the button to the bottom-right corner ─────────────────────────
# Targets the button by its aria-label (set via `help=` parameter).
_FLOAT_CSS = """
<style>
button[aria-label="Send feedback or report a bug"] {
position: fixed !important;
bottom: 2rem !important;
right: 2rem !important;
z-index: 9999 !important;
border-radius: 25px !important;
padding: 0.5rem 1.25rem !important;
box-shadow: 0 4px 16px rgba(0,0,0,0.25) !important;
font-size: 0.9rem !important;
}
</style>
"""
@st.dialog("Send Feedback", width="large")
def _feedback_dialog(page: str) -> None:
"""Two-step feedback dialog: form → consent/attachments → submit."""
from scripts.feedback_api import (
collect_context, collect_logs, collect_listings,
build_issue_body, create_forgejo_issue,
upload_attachment, screenshot_page,
)
from scripts.db import DEFAULT_DB
# ── Initialise step counter ───────────────────────────────────────────────
if "fb_step" not in st.session_state:
st.session_state.fb_step = 1
# ═════════════════════════════════════════════════════════════════════════
# STEP 1 — Form
# ═════════════════════════════════════════════════════════════════════════
if st.session_state.fb_step == 1:
st.subheader("What's on your mind?")
fb_type = st.selectbox(
"Type", ["Bug", "Feature Request", "Other"], key="fb_type"
)
fb_title = st.text_input(
"Title", placeholder="Short summary of the issue or idea", key="fb_title"
)
fb_desc = st.text_area(
"Description",
placeholder="Describe what happened or what you'd like to see...",
key="fb_desc",
)
if fb_type == "Bug":
st.text_area(
"Reproduction steps",
placeholder="1. Go to...\n2. Click...\n3. See error",
key="fb_repro",
)
col_cancel, _, col_next = st.columns([1, 3, 1])
with col_cancel:
if st.button("Cancel"):
_clear_feedback_state()
st.rerun()
with col_next:
if st.button(
"Next →",
type="primary",
disabled=not st.session_state.get("fb_title", "").strip()
or not st.session_state.get("fb_desc", "").strip(),
):
st.session_state.fb_step = 2
st.rerun()
# ═════════════════════════════════════════════════════════════════════════
# STEP 2 — Consent + attachments
# ═════════════════════════════════════════════════════════════════════════
elif st.session_state.fb_step == 2:
st.subheader("Optional: attach diagnostic data")
# ── Diagnostic data toggle + preview ─────────────────────────────────
include_diag = st.toggle(
"Include diagnostic data (logs + recent listings)", key="fb_diag"
)
if include_diag:
with st.expander("Preview what will be sent", expanded=True):
st.caption("**App logs (last 100 lines, PII masked):**")
st.code(collect_logs(100), language=None)
st.caption("**Recent listings (title / company / URL only):**")
for j in collect_listings(DEFAULT_DB, 5):
st.write(f"- {j['title']} @ {j['company']} — {j['url']}")
# ── Screenshot ────────────────────────────────────────────────────────
st.divider()
st.caption("**Screenshot** (optional)")
col_cap, col_up = st.columns(2)
with col_cap:
if st.button("📸 Capture current view"):
with st.spinner("Capturing page…"):
png = screenshot_page()
if png:
st.session_state.fb_screenshot = png
else:
st.warning(
"Playwright not available — install it with "
"`playwright install chromium`, or upload a screenshot instead."
)
with col_up:
uploaded = st.file_uploader(
"Upload screenshot",
type=["png", "jpg", "jpeg"],
label_visibility="collapsed",
key="fb_upload",
)
if uploaded:
st.session_state.fb_screenshot = uploaded.read()
if st.session_state.get("fb_screenshot"):
st.image(
st.session_state["fb_screenshot"],
caption="Screenshot preview — this will be attached to the issue",
use_container_width=True,
)
if st.button("🗑 Remove screenshot"):
st.session_state.pop("fb_screenshot", None)
st.rerun()
# ── Attribution consent ───────────────────────────────────────────────
st.divider()
submitter: str | None = None
try:
import yaml
_ROOT = Path(__file__).parent.parent
user = yaml.safe_load((_ROOT / "config" / "user.yaml").read_text()) or {}
name = (user.get("name") or "").strip()
email = (user.get("email") or "").strip()
if name or email:
label = f"Include my name & email in the report: **{name}** ({email})"
if st.checkbox(label, key="fb_attr"):
submitter = f"{name} <{email}>"
except Exception:
pass
# ── Navigation ────────────────────────────────────────────────────────
col_back, _, col_submit = st.columns([1, 3, 2])
with col_back:
if st.button("← Back"):
st.session_state.fb_step = 1
st.rerun()
with col_submit:
if st.button("Submit Feedback", type="primary"):
_submit(page, include_diag, submitter, collect_context,
collect_logs, collect_listings, build_issue_body,
create_forgejo_issue, upload_attachment, DEFAULT_DB)
def _submit(page, include_diag, submitter, collect_context, collect_logs,
collect_listings, build_issue_body, create_forgejo_issue,
upload_attachment, db_path) -> None:
"""Handle form submission: build body, file issue, upload screenshot."""
with st.spinner("Filing issue…"):
context = collect_context(page)
attachments: dict = {}
if include_diag:
attachments["logs"] = collect_logs(100)
attachments["listings"] = collect_listings(db_path, 5)
if submitter:
attachments["submitter"] = submitter
fb_type = st.session_state.get("fb_type", "Other")
type_key = {"Bug": "bug", "Feature Request": "feature", "Other": "other"}.get(
fb_type, "other"
)
labels = ["beta-feedback", "needs-triage"]
labels.append(
{"bug": "bug", "feature": "feature-request"}.get(type_key, "question")
)
form = {
"type": type_key,
"description": st.session_state.get("fb_desc", ""),
"repro": st.session_state.get("fb_repro", "") if type_key == "bug" else "",
}
body = build_issue_body(form, context, attachments)
try:
result = create_forgejo_issue(
st.session_state.get("fb_title", "Feedback"), body, labels
)
screenshot = st.session_state.get("fb_screenshot")
if screenshot:
upload_attachment(result["number"], screenshot)
_clear_feedback_state()
st.success(f"Issue filed! [View on Forgejo]({result['url']})")
st.balloons()
except Exception as exc:
st.error(f"Failed to file issue: {exc}")
def _clear_feedback_state() -> None:
for key in [
"fb_step", "fb_type", "fb_title", "fb_desc", "fb_repro",
"fb_diag", "fb_upload", "fb_attr", "fb_screenshot",
]:
st.session_state.pop(key, None)
def inject_feedback_button(page: str = "Unknown") -> None:
"""
Inject the floating feedback button. Call once per page render in app.py.
Hidden automatically in DEMO_MODE.
"""
if os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes"):
return
if not os.environ.get("FORGEJO_API_TOKEN"):
return # silently skip if not configured
st.markdown(_FLOAT_CSS, unsafe_allow_html=True)
if st.button(
"💬 Feedback",
key="__feedback_floating_btn__",
help="Send feedback or report a bug",
):
_feedback_dialog(page)
Step 2: Verify the file has no syntax errors
conda run -n job-seeker python -c "import app.feedback; print('OK')"
Expected: OK
Step 3: Commit
git add app/feedback.py
git commit -m "feat: floating feedback button + two-step dialog (Streamlit shell)"
Task 8: Wire into app.py + manual verification
Files:
- Modify:
app/app.py
Step 1: Add import and call to app/app.py
Find the with st.sidebar: block near the bottom of app/app.py (currently ends with st.caption(f"Peregrine {_get_version()}")).
Add two lines — the import near the top of the file (after the existing imports), and the call in the sidebar block:
At the top of app/app.py, after from scripts.db import ...:
from app.feedback import inject_feedback_button
At the end of the with st.sidebar: block, after st.caption(...):
inject_feedback_button(page=st.session_state.get("__current_page__", "Unknown"))
To capture the current page name, also add this anywhere early in the sidebar block (before the caption):
# Track current page for feedback context
try:
_page_name = pg.pages[st.session_state.get("page_index", 0)].title
except Exception:
_page_name = "Unknown"
inject_feedback_button(page=_page_name)
Note on page detection: Streamlit's
st.navigationdoesn't expose the current page via a simple API. Ifpg.pages[...]doesn't resolve cleanly, simplify toinject_feedback_button()with no argument — the page context is a nice-to-have, not critical.
Step 2: Verify app starts without errors
bash /Library/Development/CircuitForge/peregrine/manage.sh restart
bash /Library/Development/CircuitForge/peregrine/manage.sh logs
Expected: no Python tracebacks in logs.
Step 3: Manual end-to-end verification checklist
Open http://localhost:8502 and verify:
- A "💬 Feedback" pill button appears fixed in the bottom-right corner
- Button is visible on Home, Setup, and all other pages
- Button is NOT visible in DEMO_MODE (set
DEMO_MODE=1in.env, restart, check) - Clicking the button opens the two-step dialog
- Step 1: selecting "Bug" reveals the reproduction steps field; "Feature Request" hides it
- "Next →" is disabled until title + description are filled
- Step 2: toggling diagnostic data shows the masked preview (no real emails/phones)
- "📸 Capture current view" either shows a thumbnail or a warning about Playwright
- Uploading a PNG via file picker shows a thumbnail
- "🗑 Remove screenshot" clears the thumbnail
- Attribution checkbox shows the name/email from user.yaml
- Submitting files a real issue at https://git.opensourcesolarpunk.com/pyr0ball/peregrine/issues
- Issue has correct labels (beta-feedback, needs-triage, + type label)
- If screenshot provided, it appears as an attachment on the Forgejo issue
- Success message contains a clickable link to the issue
Step 4: Commit
git add app/app.py
git commit -m "feat: wire feedback button into app.py sidebar"
Done
All tasks complete. The feedback button is live. When moving to Vue/Nuxt, scripts/feedback_api.py is wrapped in a FastAPI route — no changes to the backend needed.
Future tasks (not in scope now):
- GitHub mirroring (add
GITHUB_TOKEN+GITHUB_REPOenv vars, addcreate_github_issue()) - Rate limiting (if beta users abuse it)
- In-app issue status tracking