# 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** ```bash 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** ```bash 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`: ```python """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** ```bash 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** ```python """ 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** ```bash 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** ```bash 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`: ```python # ── 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** ```bash 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`: ```python 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** ```bash conda run -n job-seeker pytest tests/test_feedback_api.py -k "collect_logs or collect_listings" -v ``` Expected: 6 PASSED. **Step 5: Commit** ```bash 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`: ```python # ── 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 "
" 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 "
" 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 "}) 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** ```bash 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`: ```python 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 += [ "
", "App Logs (last 100 lines)", "", "```", attachments["logs"], "```", "
", "", ] 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** ```bash conda run -n job-seeker pytest tests/test_feedback_api.py -k "build_issue_body" -v ``` Expected: 7 PASSED. **Step 5: Commit** ```bash 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`: ```python # ── 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** ```bash 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`: ```python 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** ```bash 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** ```bash conda run -n job-seeker pytest tests/test_feedback_api.py -v ``` Expected: all PASSED. **Step 6: Commit** ```bash 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`: ```python # ── 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** ```bash 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: ```python 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** ```bash conda run -n job-seeker pytest tests/test_feedback_api.py -k "screenshot" -v ``` Expected: 2 PASSED. **Step 5: Run full backend test suite** ```bash conda run -n job-seeker pytest tests/test_feedback_api.py -v ``` Expected: all PASSED. **Step 6: Commit** ```bash 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`** ```python """ 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 = """ """ @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** ```bash conda run -n job-seeker python -c "import app.feedback; print('OK')" ``` Expected: `OK` **Step 3: Commit** ```bash 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 ...`: ```python from app.feedback import inject_feedback_button ``` At the end of the `with st.sidebar:` block, after `st.caption(...)`: ```python 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): ```python # 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.navigation` doesn't expose the current page via a simple API. If `pg.pages[...]` doesn't resolve cleanly, simplify to `inject_feedback_button()` with no argument — the page context is a nice-to-have, not critical. **Step 2: Verify app starts without errors** ```bash 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=1` in `.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** ```bash 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_REPO` env vars, add `create_github_issue()`) - Rate limiting (if beta users abuse it) - In-app issue status tracking