Skip to content

feat(feedback): in-app quick feedback widget (#5662)#7143

Merged
MarkusNeusinger merged 8 commits into
mainfrom
claude/fix-anyplot-5662-1wuVb
May 18, 2026
Merged

feat(feedback): in-app quick feedback widget (#5662)#7143
MarkusNeusinger merged 8 commits into
mainfrom
claude/fix-anyplot-5662-1wuVb

Conversation

@MarkusNeusinger
Copy link
Copy Markdown
Owner

Summary

  • Add a global floating Feedback button (FAB, bottom-right) wired into RootLayout. Clicking opens a compact popover with a 500-char message field, optional reaction (👍 👎 🐛 💡 ❤️), optional email, and a "Send" button. After submit users see a "Thanks!" state that auto-closes.
  • New backend route POST /feedback persists entries to a new feedback table via FeedbackRepository. Guards: honeypot field, message length cap, reaction allow-list, per-IP rate limit (5/min using a SHA-256 IP hash).
  • Add Plausible events feedback_opened and feedback_submitted via the existing useAnalytics hook; documented in docs/reference/plausible.md.

Resolves the three open questions in the issue:

  • Where: global (visible on every page).
  • Persistence/triage: dedicated DB table, manual triage. No auto-issue and no Slack/Discord webhook in this PR.
  • Analytics: yes — open + submit fire Plausible events.

Files

  • Backend: core/database/models.py (Feedback model + FEEDBACK_REACTIONS), core/database/repositories.py (FeedbackRepository), core/database/__init__.py (exports), alembic/versions/c5d7e9f1a3b2_add_feedback_table.py (new migration off the current head 3a7e1b5c0c4f), api/schemas.py (FeedbackRequest/Response), api/routers/feedback.py, api/routers/__init__.py, api/main.py.
  • Frontend: app/src/components/FeedbackWidget.tsx, app/src/components/RootLayout.tsx (mounts widget).
  • Tests: unit tests/unit/api/test_feedback_router.py (7), integration tests/integration/api/test_api_endpoints.py::TestFeedbackEndpoint (8) and tests/integration/test_repositories.py::TestFeedbackRepository (2), frontend app/src/components/FeedbackWidget.test.tsx (6).
  • Docs: docs/reference/plausible.md adds the two new events.

Test plan

  • uv run ruff check . && uv run ruff format --check .
  • uv run pytest tests/unit tests/integration — 1505 passed
  • yarn tsc --noEmit (clean)
  • yarn test — 465 passed across 53 files
  • Manual smoke (against a deployed env): open the FAB, send feedback, verify a row lands in the feedback table; trigger 6 submissions in a row from one IP and confirm the 6th returns 429; verify the honeypot path doesn't write.
  • Run the alembic migration round-trip against Postgres before the first deploy (alembic upgrade head then downgrade -1 then upgrade head).

https://claude.ai/code/session_01D5QExULwc4wAGZyHeTiC89


Generated by Claude Code

Add a global floating widget so non-coder users can send a short note
without opening a GitHub issue. Submissions go to a new `feedback` table
behind `POST /feedback`, guarded by a honeypot, length cap, reaction
allow-list, and per-IP rate limit. Open/submit events fire through the
existing Plausible analytics hook.

https://claude.ai/code/session_01D5QExULwc4wAGZyHeTiC89
Copilot AI review requested due to automatic review settings May 17, 2026 21:35
Comment thread app/src/components/FeedbackWidget.tsx Fixed
CodeQL flagged Math.random() in the newSessionId fallback. The id is an
opaque correlation handle (not a credential), but there's no reason to
reach for Math.random when crypto.getRandomValues is universally available
in any browser that has window.crypto.

https://claude.ai/code/session_01D5QExULwc4wAGZyHeTiC89
@codecov
Copy link
Copy Markdown

codecov Bot commented May 17, 2026

Codecov Report

❌ Patch coverage is 78.19149% with 82 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
app/src/components/FeedbackWidget.tsx 71.03% 42 Missing ⚠️
app/src/pages/DebugPage.tsx 58.62% 24 Missing ⚠️
core/database/repositories.py 62.85% 13 Missing ⚠️
api/routers/debug.py 95.74% 2 Missing ⚠️
api/routers/feedback.py 98.33% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an in-app quick feedback flow spanning the React app, FastAPI backend, database schema, tests, and Plausible analytics documentation.

Changes:

  • Adds a global floating feedback widget mounted in RootLayout.
  • Adds POST /feedback, persistence via a new Feedback model/repository, and Alembic migration.
  • Adds backend/frontend tests and documents the new Plausible events.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
app/src/components/FeedbackWidget.tsx Implements the floating feedback FAB, popover form, submission, and analytics events.
app/src/components/FeedbackWidget.test.tsx Adds component tests for widget rendering, submission, errors, and honeypot accessibility.
app/src/components/RootLayout.tsx Mounts the feedback widget globally.
api/routers/feedback.py Adds the feedback submission endpoint, validation, rate limiting, and persistence.
api/routers/__init__.py Exports the new feedback router.
api/main.py Registers the feedback router with the FastAPI app.
api/schemas.py Adds feedback request/response schemas.
core/database/models.py Adds the Feedback model and allowed reaction constants.
core/database/repositories.py Adds FeedbackRepository.
core/database/__init__.py Re-exports feedback model/repository symbols.
alembic/versions/c5d7e9f1a3b2_add_feedback_table.py Adds the feedback table migration and indexes.
tests/unit/api/test_feedback_router.py Adds isolated unit coverage for feedback endpoint guard logic.
tests/integration/api/test_api_endpoints.py Adds integration coverage for /feedback.
tests/integration/test_repositories.py Adds integration coverage for FeedbackRepository.
docs/reference/plausible.md Documents feedback analytics events.

"""Create the feedback table plus indexes for recent-first browsing and rate-limit lookups."""
op.create_table(
"feedback",
sa.Column("id", sa.String(36), primary_key=True, nullable=False),
Comment thread api/routers/feedback.py Outdated
Comment on lines +74 to +75
since = datetime.now(timezone.utc) - RATE_LIMIT_WINDOW
recent = await repo.count_recent_by_ip(ip_hash, since)
Comment thread api/routers/feedback.py
Comment on lines +32 to +36
"""Resolve the client IP, preferring x-forwarded-for (Cloud Run + CF)."""
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
# x-forwarded-for can be a comma-separated chain; the first entry is the original client.
return forwarded.split(",")[0].strip()
Comment thread api/routers/feedback.py
Comment on lines +73 to +77
if ip_hash:
since = datetime.now(timezone.utc) - RATE_LIMIT_WINDOW
recent = await repo.count_recent_by_ip(ip_hash, since)
if recent >= RATE_LIMIT_MAX:
raise HTTPException(status_code=429, detail="Too many feedback submissions, please slow down")
Comment on lines +72 to +75

const ensureSessionId = (): string => {
if (sessionId) return sessionId;
const fresh = newSessionId();
Comment thread core/database/repositories.py Outdated
model = Feedback
updatable_fields = frozenset()

async def count_recent_by_ip(self, ip_hash: str, since) -> int:
Comment thread api/routers/feedback.py
Comment on lines +40 to +42
def _hash_ip(ip: str) -> str:
"""SHA-256 of the IP, hex. Used for rate-limit lookups only — never reversed."""
return hashlib.sha256(ip.encode("utf-8")).hexdigest() if ip else ""
Comment on lines +33 to +34
if (RESERVED_TOP_LEVEL.has(segments[0])) return undefined;
return segments[0];
Builds on top of the in-app feedback widget (#5662) with several layered
follow-ups gathered during local iteration:

- Schema fixes: `feedback.id` was created as varchar(36) but the model
  uses `UniversalUUID`, so reads after insert failed on Postgres with
  "operator does not exist: character varying = uuid". Migrate to `uuid`
  (Postgres-only). Rename `email` -> free-form `contact` column. Make
  `message` nullable so reaction-only submissions are allowed. Add a
  `status` triage column constrained to new / in_progress / done /
  wont_solve, plus matching CHECK constraints and model exports.
- Router cleanup: tag UTC timestamps with `+00:00` so the browser does
  not mis-parse them as local time, drop the `email` @-validation, allow
  message OR reaction (one must be present), and only accept the four
  reactions (heart removed).
- Anti-spam: silent-drop messages with >=2 http(s) URLs (link-stuffing
  SEO spam) and the same message text repeated by the same IP/session
  within 10 minutes. Both branches mirror the honeypot behaviour (200
  OK with no DB write) so bots cannot distinguish accepted from
  suppressed submissions. Issue #7249 tracks Cloudflare Turnstile as
  the follow-up for higher-skill bots.
- Widget UX: FAB now opens a vertical mini-stack (👍 / 👎 / 💬). Thumbs
  fire a reaction-only submit with the current URL and a 1.2s "Thanks"
  toast; the chat-bubble expands the full dialog (free-text, four
  reactions, free-form contact, read-only Page line). FAB and stack
  share a neutral, slightly translucent surface with hover at full
  opacity. On narrow viewports the whole stack lifts as the footer
  scrolls in, so the FAB centre tracks the footer's top edge instead
  of overlapping the legal link.
- /debug triage: three new admin-gated endpoints — top-pages by 👍 /
  👎 grouped by path, message list filterable by status (with `open`
  pseudo-value mapping to new + in_progress), and PATCH for status.
  DebugPage gains the matching sections with optimistic status edits.
- Router hygiene: provide a HydrateFallback element so React Router
  stops warning about lazy-route hydration on every page load.
- Docs: refresh plausible.md to reflect the new event props
  (has_contact, mode) and reaction set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated 13 comments.

Comments suppressed due to low confidence (2)

tests/integration/api/test_api_endpoints.py:325

  • This test still assumes an email field with email validation, but the API now accepts a free-form contact field and does not define or validate email. The request will be accepted because email is ignored, so the expected 400 is no longer correct.
    async def test_invalid_email_rejected(self, client):
        """Should reject obviously malformed emails with 400."""
        response = await client.post("/feedback", json={"message": "hello", "email": "not-an-email"})
        assert response.status_code == 400

core/database/repositories.py:381

  • since is untyped here even though the repository layer consistently annotates method parameters and returns. Adding the datetime type is especially useful for this duplicate-window query because the router intentionally passes a UTC-naive cutoff.
        message: str,
        ip_hash: str | None,
        session_id: str | None,
        since,
    ) -> bool:

Comment thread api/routers/feedback.py
"message": message,
"reaction": reaction,
"contact": contact,
"path": (payload.path or None) and payload.path[:500],
Comment thread api/routers/feedback.py
Comment on lines +41 to +45
"""Resolve the client IP, preferring x-forwarded-for (Cloud Run + CF)."""
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
# x-forwarded-for can be a comma-separated chain; the first entry is the original client.
return forwarded.split(",")[0].strip()
Comment thread app/src/components/FeedbackWidget.tsx Outdated
Comment on lines +141 to +144
const currentPath = useMemo(
() => (typeof window !== 'undefined' ? window.location.pathname + window.location.search : ''),
[mode]
);
{mode === 'quick' && (
<ClickAwayListener onClickAway={handleQuickAway}>
<Box
role="menu"
Comment thread app/src/components/FeedbackWidget.tsx Outdated
Comment on lines +208 to +212
await fetch(`${API_URL}/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildPayload({ message: null, reaction: r, contact: null })),
});
Comment on lines +279 to +282
position: 'fixed',
bottom: { xs: 12, sm: 16 },
right: { xs: 12, sm: 16 },
zIndex: 1300,
Comment thread api/routers/feedback.py
Comment on lines +49 to +51
def _hash_ip(ip: str) -> str:
"""SHA-256 of the IP, hex. Used for rate-limit lookups only — never reversed."""
return hashlib.sha256(ip.encode("utf-8")).hexdigest() if ip else ""
Comment on lines +330 to +334
for _ in range(5):
ok = await client.post("/feedback", json={"message": "spam"}, headers=headers)
assert ok.status_code == 200

blocked = await client.post("/feedback", json={"message": "spam"}, headers=headers)
payload = {
"message": "Bug on mobile",
"reaction": "bug",
"email": "user@example.com",
Comment on lines +37 to +41
const REACTIONS = [
{ value: 'thumbs_up', label: 'thumbs up', glyph: '👍' },
{ value: 'thumbs_down', label: 'thumbs down', glyph: '👎' },
{ value: 'idea', label: 'idea', glyph: '💡' },
{ value: 'bug', label: 'bug', glyph: '🪲' },
MarkusNeusinger and others added 2 commits May 18, 2026 16:16
Merges the language-first-class work and other main updates onto the
feedback-iteration branch, resolves trivial import conflicts in
`core/database/{__init__,repositories}.py`, and applies the high-signal
Copilot review notes on PR #7143:

- `useMemo`-cached `currentPath` was tied to the widget's mode state, so
  the first `feedback_opened` after a client-side navigation tracked the
  previous URL. Replace with a fresh `getCurrentPath()` at every read
  site (track, payload, dialog "Page: ..." line).
- Quick 👍/👎 submit no longer flashes "Thanks" when the server returns
  429/500 or the silent spam-filter path. Wait for `response.ok` before
  showing the toast and tracking.
- Add `map` to `RESERVED_TOP_LEVEL` (and the matching workflow
  `RESERVED_SLUGS` list) — feedback from `/map` was being persisted
  with `spec_id: "map"` even though it isn't a spec page.
- Type the `since` parameters on `FeedbackRepository.count_recent_by_ip`
  and `has_recent_duplicate` so timezone-naive cutoff mistakes are
  caught by the type checker.
- Integration tests on `/feedback` were still sending the dropped
  `email` field and reusing the same message for the rate-limit case
  (which now trips the duplicate-suppression filter). Move to `contact`,
  drop the obsolete `test_invalid_email_rejected`, and use distinct
  messages in the rate-limit assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 18, 2026 14:18
MarkusNeusinger and others added 2 commits May 18, 2026 16:23
CI was failing on `Run Linting` because `ruff format --check` flagged the
two new feedback migrations, the debug router, the models, repositories,
and the router-test file as needing reformatting. Reformat without any
behavioural change.

Adds four widget tests to cover the mini-stack interaction paths that
codecov flagged as missing on the frontend patch (target 80%): a second
FAB click closes the stack, ClickAway outside the stack closes it, a
500 response from the quick-submit path leaves the Thanks toast hidden,
and submitting a reaction-only entry through the full dialog forwards
the chosen reaction in the request body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated 10 comments.


# revision identifiers, used by Alembic.
revision: str = "c5d7e9f1a3b2"
down_revision: str | None = "3a7e1b5c0c4f"
Comment thread api/routers/feedback.py
Comment on lines +122 to +123
"path": (payload.path or None) and payload.path[:500],
"spec_id": (payload.spec_id or None) and payload.spec_id[:100],
Comment on lines +22 to +24
const MAX_MESSAGE_LENGTH = 500;
const SESSION_KEY = 'anyplot_feedback_session';
const THANKS_TIMEOUT_MS = 1200;
Comment on lines +435 to +445
<TextField
fullWidth
size="small"
placeholder="Name or email (optional)"
value={contact}
onChange={(e) => setContact(e.target.value)}
slotProps={{ htmlInput: { maxLength: 255, 'aria-label': 'Contact (optional)' } }}
disabled={submitting}
sx={{ mb: 1 }}
/>

Comment thread api/routers/feedback.py
Comment on lines +49 to +51
def _hash_ip(ip: str) -> str:
"""SHA-256 of the IP, hex. Used for rate-limit lookups only — never reversed."""
return hashlib.sha256(ip.encode("utf-8")).hexdigest() if ip else ""
Comment thread api/routers/feedback.py
Comment on lines +41 to +45
"""Resolve the client IP, preferring x-forwarded-for (Cloud Run + CF)."""
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
# x-forwarded-for can be a comma-separated chain; the first entry is the original client.
return forwarded.split(",")[0].strip()
Comment thread api/routers/feedback.py
Comment on lines +98 to +102
if ip_hash:
since = now_utc_naive - RATE_LIMIT_WINDOW
recent = await repo.count_recent_by_ip(ip_hash, since)
if recent >= RATE_LIMIT_MAX:
raise HTTPException(status_code=429, detail="Too many feedback submissions, please slow down")
{mode === 'quick' && (
<ClickAwayListener onClickAway={handleQuickAway}>
<Box
role="menu"
Comment on lines +352 to +355
const updateFeedbackStatus = async (id: string, newStatus: FeedbackStatus) => {
// Optimistic update — revert on failure so the dropdown stays truthful.
const prev = feedbackMessages;
setFeedbackMessages(prev.map(m => (m.id === id ? { ...m, status: newStatus } : m)));
Comment on lines +37 to +42
const REACTIONS = [
{ value: 'thumbs_up', label: 'thumbs up', glyph: '👍' },
{ value: 'thumbs_down', label: 'thumbs down', glyph: '👎' },
{ value: 'idea', label: 'idea', glyph: '💡' },
{ value: 'bug', label: 'bug', glyph: '🪲' },
] as const;
@MarkusNeusinger MarkusNeusinger merged commit 9a0971a into main May 18, 2026
8 of 9 checks passed
@MarkusNeusinger MarkusNeusinger deleted the claude/fix-anyplot-5662-1wuVb branch May 18, 2026 14:56
MarkusNeusinger added a commit that referenced this pull request May 18, 2026
Version bump for the v2.4.0 release. Release notes will be attached to
the tag once this lands.

## Highlights since v2.3.0

- **R / ggplot2 added as the 10th library** + multi-language pipeline
(#6944, #6961, #7052). 30 ggplot2 implementations landed across
foundational plot types.
- **In-app feedback widget** (#7143).
- **Stats page** with Plausible visitors chart + daily-impl timeline
(#6608).
- **Language across the site**: `/plots?lang=` filtering, cross-language
carousel, language in URLs and titles (#7141, #7142, #7144).
- **UI polish**: pseudo-function styling for 404 / footer / empty state
/ library card (#6436); mobile fixes for `/stats`, `/mcp`, breadcrumb +
FAB (#6902, #7283).
- **Pipeline**: review-retry listener + stuck-jobs watchdog (#6084);
daily-regen 2h → hourly (#6943).
- **Dependencies**: mypy 1.20→2.1, urllib3 2.6→2.7, authlib bump,
react/mui/python-minor groups.
- ~1200 implementation regenerations across all 10 libraries.

No SemVer-breaking changes.

**Full Changelog:**
v2.3.0...main

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MarkusNeusinger added a commit that referenced this pull request May 18, 2026
## Problem

`Sync: PostgreSQL` has been failing on every push to `main` since ~17:55
with:

```
ERROR [alembic.util.messaging] Multiple head revisions are present for given argument 'head'; please specify a specific target revision, '<branchname>@Head' to narrow to a specific head, or 'heads' for all heads
FAILED: Multiple head revisions are present...
```

PRs #7142 (language descriptions) and #7143 (feedback widget) both
branched from \`3a7e1b5c0c4f\` and merged independently, leaving two
alembic heads:

- `c5f9a3d72be1` — update_language_descriptions
- `e5b1c9d4a7f2` — feedback_uuid_and_status

No merge revision was added, so `alembic upgrade head` is ambiguous.
Production DB is stuck on whatever was the last successful sync.

## Fix

Add a no-op merge revision \`7efe9fc8bde1\` joining both heads. Verified
locally:

- \`alembic heads\` → single head \`7efe9fc8bde1\`
- \`alembic upgrade head --sql\` produces a clean transactional plan

## Test plan

- [ ] CI green on this PR
- [ ] After merge, `Sync: PostgreSQL` workflow goes green on the next
push to `main`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants