feat(feedback): in-app quick feedback widget (#5662)#7143
Merged
Conversation
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
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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Contributor
There was a problem hiding this comment.
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 newFeedbackmodel/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 on lines
+74
to
+75
| since = datetime.now(timezone.utc) - RATE_LIMIT_WINDOW | ||
| recent = await repo.count_recent_by_ip(ip_hash, since) |
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 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(); |
| model = Feedback | ||
| updatable_fields = frozenset() | ||
|
|
||
| async def count_recent_by_ip(self, ip_hash: str, since) -> int: |
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>
Contributor
There was a problem hiding this comment.
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
emailfield with email validation, but the API now accepts a free-formcontactfield and does not define or validateemail. The request will be accepted becauseemailis 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
sinceis 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:
| "message": message, | ||
| "reaction": reaction, | ||
| "contact": contact, | ||
| "path": (payload.path or None) and payload.path[:500], |
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 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 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 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: '🪲' }, |
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>
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>
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision: str = "c5d7e9f1a3b2" | ||
| down_revision: str | None = "3a7e1b5c0c4f" |
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 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
+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 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
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>
2 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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.POST /feedbackpersists entries to a newfeedbacktable viaFeedbackRepository. Guards: honeypot field, message length cap, reaction allow-list, per-IP rate limit (5/min using a SHA-256 IP hash).feedback_openedandfeedback_submittedvia the existinguseAnalyticshook; documented indocs/reference/plausible.md.Resolves the three open questions in the issue:
Files
core/database/models.py(Feedbackmodel +FEEDBACK_REACTIONS),core/database/repositories.py(FeedbackRepository),core/database/__init__.py(exports),alembic/versions/c5d7e9f1a3b2_add_feedback_table.py(new migration off the current head3a7e1b5c0c4f),api/schemas.py(FeedbackRequest/Response),api/routers/feedback.py,api/routers/__init__.py,api/main.py.app/src/components/FeedbackWidget.tsx,app/src/components/RootLayout.tsx(mounts widget).tests/unit/api/test_feedback_router.py(7), integrationtests/integration/api/test_api_endpoints.py::TestFeedbackEndpoint(8) andtests/integration/test_repositories.py::TestFeedbackRepository(2), frontendapp/src/components/FeedbackWidget.test.tsx(6).docs/reference/plausible.mdadds the two new events.Test plan
uv run ruff check . && uv run ruff format --check .uv run pytest tests/unit tests/integration— 1505 passedyarn tsc --noEmit(clean)yarn test— 465 passed across 53 filesfeedbacktable; trigger 6 submissions in a row from one IP and confirm the 6th returns 429; verify the honeypot path doesn't write.alembic upgrade headthendowngrade -1thenupgrade head).https://claude.ai/code/session_01D5QExULwc4wAGZyHeTiC89
Generated by Claude Code