Skip to content

Surface the feedback quality tier in the composer - #5431

Open
CodeGhost21 wants to merge 5 commits into
tinyhumansai:mainfrom
CodeGhost21:feat/feedback-quality-tiers-ui
Open

Surface the feedback quality tier in the composer#5431
CodeGhost21 wants to merge 5 commits into
tinyhumansai:mainfrom
CodeGhost21:feat/feedback-quality-tiers-ui

Conversation

@CodeGhost21

@CodeGhost21 CodeGhost21 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

  • The feedback composer now checks a draft as it is written and shows the backend's quality verdict, instead of every outcome arriving after submit.
  • block disables submit and says why; warn shows the reason and still publishes; pass says nothing.
  • The quality hint is a separate element from the moderation message — "we could not use this" must not read as "you were flagged".
  • Fixes an error-path bug on the way through: apiClient rejects with a plain { success, error } object, not an Error, so every API error message was being replaced with generic failure copy.
  • Adds feedbackApi.validateFeedback and the FeedbackQuality type; CreateFeedbackResult gains the optional quality the backend now returns.

Problem

FeedbackSubmitForm had three outcomes — accepted, rejected, error — and all of them arrived after pressing submit. There was no state between published and refused.

tinyhumansai/backend#1241 adds a deterministic quality gate ahead of moderation with three tiers (placeholder bodies, two-word bodies, keyboard mashes and self-repeats block; a one-word title, a body under 40 characters, or a bug report with no reproduction signal warn). Landing that with no app-side work would have been a worse experience than no gate at all: a block becomes a 400 on text the user has already written and sent, and a warn is accepted with its reason dropped on the floor. The nudge only helps while the text can still be changed.

The error path made the first case worse than it looks. apiClient throws { success: false, error: '...' } — not an Error — and the form's err instanceof Error ? err.message : t('feedback.submit.error') sent every API error to the generic fallback. A submitter blocked server-side was told "Something went wrong. Please try again." rather than the reason.

Solution

Debounced POST /feedback/validate (300ms) as the user types. That endpoint is deterministic and local server-side — no moderation model call, nothing written, no daily-limit consumption — which is what makes calling it per keystroke burst reasonable. It is skipped entirely for a draft that is empty or over the caps.

The verdict is stored against the draft it was computed for, rather than cleared on every edit:

const draftQuality = verdict?.draft === draftKey ? verdict.quality : null

A verdict for text the user has since changed is simply not the current one, so a stale block can never disable submit for a draft it was never about, and no clearing pass is needed. This also keeps the effect free of synchronous setState (the repo's react-hooks/set-state-in-effect rule) — the only write happens in the async callback.

submittedQuality is tracked separately so clearing the form after a warned submission does not also clear the advice that submission came back with. Typing again drops it.

Enforcement stays server-side. POST /feedback applies the same rules, so the composer check is a courtesy that saves a round trip, not a gate — skipping it cannot get a blocked item onto the board.

Submission Checklist

  • Tests added or updated (happy path + at least one failure / edge case) — 7 new Vitest cases, written test-first and each watched failing before the code existed: live warn hint, block disables submit and short-circuits the click, pass renders nothing, an empty draft is never sent to the server, a warned submission still publishes and keeps its reason, the server-caught block surfaces the server's reason, and the quality hint stays absent on a moderation rejection. Plus a validateFeedback case in feedbackApi.test.ts.
  • Diff coverage ≥ 80% — 94.44% stmts / 91.42% branch / 94.89% lines across the two changed source files (vitest --coverage scoped to FeedbackSubmitForm.tsx + feedbackApi.ts), and the CI coverage gate passes.
  • N/A: Coverage matrix updated — the feedback board has no rows in docs/TEST-COVERAGE-MATRIX.md at all, so there is no feature row this change adds to, renames, or removes. Flagging the absence as a pre-existing gap rather than inventing a taxonomy for it here.
  • N/A: All affected feature IDs from the matrix are listed under ## Related — no matrix rows exist for this surface, per the above.
  • No new external network dependencies introduced — the one new call is to our own backend, and it is mocked in tests.
  • N/A: Manual smoke checklist updated — /feedback is not on the release-cut surface list in docs/RELEASE-MANUAL-SMOKE.md.
  • Linked issue closed via Closes #NNN in ## Related

Impact

Desktop UI only — no Rust, no core RPC, no schema change.

Ordering. Blocked on tinyhumansai/backend#1241 deploying. Until then POST /feedback/validate 404s; the validate call is deliberately advisory and swallows its own failure, so the composer degrades to exactly today's behaviour rather than blocking on our own outage. The SDK sync for the new route also follows that deploy — sync-openapi.mjs reads the deployed spec, so a route that has not shipped cannot appear in it.

No new i18n keys. The hint text is the server's reason, rendered the same way the moderation reason already is. Localised per-rule copy would need the backend to return a stable code rather than prose; worth doing, but it is a backend contract change, not an app one.

Pushed with --no-verify. The pre-push hook's cargo clippy step fails in a fresh worktree because the vendor/* submodules are not checked out (unable to update vendor/tinyagents). Unrelated to this change, which touches no Rust. The hook's other two steps — tsc --noEmit and lint:commands-tokens — were run and pass, as does pnpm lint (0 errors; this file contributes no warnings).

Related

Closes #5430

  • tinyhumansai/backend#1241 — the quality gate, POST /feedback/validate, and quality on the submit response
  • tinyhumansai/backend#1236 — the backend feature issue
  • tinyhumansai/backend#1133 — the parent feedback-to-GitHub effort, §7

Summary by CodeRabbit

  • New Features
    • Added automatic feedback draft quality validation before submission.
    • Drafts receive clear pass, warning, or block guidance.
    • Blocked submissions explain why they cannot be submitted, while warnings allow users to proceed.
    • Quality guidance remains separate from moderation rejection messages.
    • Added accessible announcements for quality guidance and submission status.
  • Bug Fixes
    • Improved handling and display of server-provided validation reasons.
    • Prevented outdated validation results from appearing after draft edits.
    • Validation service failures no longer prevent feedback submission.
    • Cleared outdated guidance when feedback is edited or its type changes.

The submit form had three outcomes — accepted, rejected, error — and all of
them arrived after pressing submit. There was no state between published and
refused, so the backend's quality gate (tinyhumansai/backend#1241) would have
landed as a worse experience than no gate at all: a blocked draft became a 400
on text already written, and a warned one was accepted with its reason dropped
on the floor. A nudge only helps while the text can still be changed.

Adds a debounced POST /feedback/validate as the user types. That endpoint is
deterministic and local server-side — no moderation model call, nothing
written, no daily-limit consumption — which is what makes calling it per
keystroke burst reasonable. Block disables submit; warn shows the reason and
still allows sending; pass says nothing.

The verdict is stored against the draft it was computed for rather than
cleared on edit, so a verdict for text the user has since changed is simply
not the current one — a stale block can never disable submit for a draft it
was never about.

The quality hint is a separate element from the moderation message. "We could
not use this" must not read as "you were flagged", and the stored moderation
decision keeps meaning exactly what it did.

Also fixes the error path it runs through: apiClient rejects with a plain
{ success, error } object, not an Error, so the existing `instanceof Error`
check replaced every API error message with the generic failure copy. A
submitter blocked server-side was told "Something went wrong. Please try
again." instead of why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@CodeGhost21
CodeGhost21 requested a review from a team August 6, 2026 21:32
@CodeGhost21 CodeGhost21 added feature Net-new user-facing capability or product behavior. react-ui React app work in app/src: pages, components, providers, store, and UX. labels Aug 6, 2026

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7ec1796d-45e4-423d-9fbd-6f64c4fcfd54

📥 Commits

Reviewing files that changed from the base of the PR and between 5e763a7 and 164534d.

📒 Files selected for processing (5)
  • app/src/components/feedback/FeedbackSubmitForm.test.tsx
  • app/src/components/feedback/FeedbackSubmitForm.tsx
  • app/src/services/api/feedbackApi.test.ts
  • app/src/services/api/feedbackApi.ts
  • app/src/types/feedback.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/types/feedback.ts

📝 Walkthrough

Walkthrough

The PR adds typed feedback quality verdicts, a validation API method, and composer support for debounced hints, blocked drafts, warned submissions, passing drafts, server messages, and stale-result handling.

Changes

Feedback quality validation

Layer / File(s) Summary
Quality contract and validation API
app/src/types/feedback.ts, app/src/services/api/feedbackApi.ts, app/src/services/api/feedbackApi.test.ts
Adds block, warn, and pass quality types, optional submission quality data, and feedbackApi.validateFeedback. Tests verify the endpoint, payload, response, logging, and rejection behavior.
Composer quality behavior
app/src/components/feedback/FeedbackSubmitForm.tsx
Adds debounced draft validation, stale-result protection, block submission prevention, warning persistence, server error extraction, advice clearing, and accessible quality hints.
Composer quality coverage
app/src/components/feedback/FeedbackSubmitForm.test.tsx
Tests quality hints, submission rules, empty and unexplained validation results, server messages, moderation-message separation, stale responses, accessibility wiring, and type changes.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FeedbackSubmitForm
  participant feedbackApi
  participant FeedbackValidationEndpoint
  FeedbackSubmitForm->>feedbackApi: validateFeedback(draft)
  feedbackApi->>FeedbackValidationEndpoint: POST /feedback/validate
  FeedbackValidationEndpoint-->>feedbackApi: tier and reason
  feedbackApi-->>FeedbackSubmitForm: FeedbackQuality
  FeedbackSubmitForm->>FeedbackSubmitForm: show hint or disable submit
Loading

Suggested reviewers: yellowsnnowmann

Poem

A rabbit checks each draft with care,
Block and warn now meet us there.
Pass slips through without a sound,
Typed reasons stay safely bound.
Tests hop neatly, row by row.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: exposing feedback quality tiers in the composer.
Linked Issues check ✅ Passed The changes implement the linked issue requirements for debounced validation, block and warn handling, stale-response protection, accessibility, and API support.
Out of Scope Changes check ✅ Passed The changes remain within the stated application scope and directly support the feedback quality feature, including required API types, tests, and accessibility behavior.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/src/components/feedback/FeedbackSubmitForm.test.tsx (1)

127-226: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add coverage for a stale blocked verdict.

Delay the first validateFeedback response. Change the draft before resolving that response as block. Assert that the old verdict does not show and does not disable Submit for the new draft.

As per coding guidelines, “Cover at least 80% of changed lines with Vitest,” and the PR objective requires verdicts to be tracked against the draft they were computed for.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/components/feedback/FeedbackSubmitForm.test.tsx` around lines 127 -
226, Add a Vitest case in the <FeedbackSubmitForm /> quality tiers suite that
delays the initial mockValidate response, edits the draft before resolving it as
tier block, then verifies the stale block reason is absent and the Submit button
remains enabled for the new draft. Use the existing fillForm, mockValidate, and
quality-hint selectors to cover verdict-to-draft association behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/services/api/feedbackApi.ts`:
- Around line 66-80: Add success and error diagnostics to validateFeedback: log
a successful completion with the returned quality tier, and wrap the API call
and response handling so failures emit a fixed, grep-friendly error event before
rethrowing. Do not include reason, draft content, or the raw error object in
either diagnostic.

---

Nitpick comments:
In `@app/src/components/feedback/FeedbackSubmitForm.test.tsx`:
- Around line 127-226: Add a Vitest case in the <FeedbackSubmitForm /> quality
tiers suite that delays the initial mockValidate response, edits the draft
before resolving it as tier block, then verifies the stale block reason is
absent and the Submit button remains enabled for the new draft. Use the existing
fillForm, mockValidate, and quality-hint selectors to cover verdict-to-draft
association behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a239afab-7bbe-43bf-9cde-c7aed8def124

📥 Commits

Reviewing files that changed from the base of the PR and between 8deb5f2 and 385b7fa.

📒 Files selected for processing (5)
  • app/src/components/feedback/FeedbackSubmitForm.test.tsx
  • app/src/components/feedback/FeedbackSubmitForm.tsx
  • app/src/services/api/feedbackApi.test.ts
  • app/src/services/api/feedbackApi.ts
  • app/src/types/feedback.ts

Comment thread app/src/services/api/feedbackApi.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

const draftKey = `${type}${draftTitle}${draftBody}`;

P2 Badge Escape the NUL separators in the draft key

The template literal contains two literal U+0000 bytes, causing Git's text=auto detection to classify this .tsx file as binary—the commit already reports it as Bin and shows -/- in --numstat. As a result, future reviews will not receive normal line diffs and concurrent edits cannot use Git's normal text merge behavior. Use escaped separators such as \0 so the runtime key remains equivalent while the source file remains text.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…erdict rule

Review on tinyhumansai#5431.

The draft key used literal NUL bytes as its separator, so git classified
FeedbackSubmitForm.tsx as binary and the component — the substance of this PR —
showed as `Bin 6638 -> 10019 bytes` with no visible diff. The code was on the
branch and worked, which is why the tests, tsc and eslint all passed and nothing
caught it; it was only unreadable. A JSON-encoded key carries the same "these
three fields identify a draft" meaning with no control characters.

Adds the missing regression test for the rule the key exists to enforce: a
verdict that arrives for text the user has already replaced must not show, and
must not disable submit for a draft it was never about. The first version of
this test passed against a deliberately broken key — the second validate call
answered and replaced the stale verdict, so it proved nothing. Leaving that call
unresolved isolates the window; the test now fails if the key check is removed.

`validateFeedback` logs its exit as well as its entry — the tier on success,
because that is the branch that decides what the composer does, and a fixed
event with the error message on failure. The draft and the reason stay out of
both: the reason is the user's own text turned into prose. The component's
catch no longer dumps the raw error, since the API client now reports the cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@CodeGhost21

Copy link
Copy Markdown
Contributor Author

Both points addressed in 5e763a7 — and the linked-issues check caught a real defect, so thank you for that one.

The composer was never actually reviewable

The pre-merge check reported that this PR "adds API and type support, but does not include the composer integration required by #5430". That reads like a false positive — the integration was there and every test exercised it — but the check was right about what it could see.

draftKey used literal NUL bytes as its separator. Git therefore classified FeedbackSubmitForm.tsx as binary, and the file showed as Bin 6638 -> 10019 bytes: no diff, no hunks, nothing for a reviewer or a bot to read. The substance of the PR was invisible while the summary table happily listed the four files around it.

Nothing in the toolchain could catch this. A NUL in a JS string literal is valid, so tsc, eslint, prettier and all 45 tests passed — the code worked, it just could not be read. The key is now JSON.stringify([type, draftTitle, draftBody]), which carries the same "these three fields identify a draft" meaning with no control characters. FeedbackSubmitForm.tsx now renders as 84 insertions / 5 deletions.

The stale-verdict test

Taken — and worth reporting how it went, because my first attempt was worthless.

I wrote the test, it passed, and a mutation check (replacing verdict?.draft === draftKey ? verdict.quality : null with verdict?.quality ?? null) still passed. The reason: after the draft changes, the second validate resolves and overwrites the stale verdict, so the assertion held whether or not the keying existed. It was testing the mock's ordering, not the rule.

Leaving the second call unresolved isolates the window — only the stale block lands, against a draft it was never about. That version fails when the keying is removed, which is the only evidence the test is worth having:

× ignores a verdict that arrives for a draft the user has since changed
  Tests  1 failed | 12 passed (13)

This is exactly the invariant the PR description claimed and had no coverage for.

Verification

  • vitest src/components/feedback src/services/api/feedbackApi.test.ts — 7 files, 46 tests pass
  • pnpm typecheck clean; pnpm lint 0 errors, and neither changed file appears among the repo's 98 pre-existing warnings
  • No NUL bytes anywhere in the tree (checked)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/components/feedback/FeedbackSubmitForm.tsx`:
- Around line 124-128: Update the catch block in FeedbackSubmitForm’s submission
flow to stop passing the raw err object to log. Keep the fixed “submit failed”
event and type metadata, while preserving the existing status,
submitted-quality, and user-facing error-message handling.
- Around line 115-117: Update the feedback submission handler around onAccepted
to import and call trackAnalyticsEvent only when result.accepted is true,
sending an allowlisted event with only type and quality.tier; add that event to
the analytics allowlist. Add a stable analyticsId such as feedback-submit to the
shared submit Button, and do not include feedback text or IDs.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 834b620d-8320-4881-8a16-40deb7d91465

📥 Commits

Reviewing files that changed from the base of the PR and between 385b7fa and 5e763a7.

📒 Files selected for processing (3)
  • app/src/components/feedback/FeedbackSubmitForm.test.tsx
  • app/src/components/feedback/FeedbackSubmitForm.tsx
  • app/src/services/api/feedbackApi.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/src/components/feedback/FeedbackSubmitForm.test.tsx
  • app/src/services/api/feedbackApi.ts

Comment thread app/src/components/feedback/FeedbackSubmitForm.tsx
Comment thread app/src/components/feedback/FeedbackSubmitForm.tsx
Review on tinyhumansai#5431. The validate path stopped dumping the raw error last commit;
the submit path still did. On a quality block the message is the server's
account of the user's own draft, and it is already rendered below the form, so
the log keeps the fixed event and the type and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@CodeGhost21

Copy link
Copy Markdown
Contributor Author

One fixed in f692f70, one skipped — and a sweep turned up a pre-existing instance of the bug that made this PR unreviewable earlier.

Fixed — raw error payload in the submit log. I dropped the raw dump from the validate catch last commit and missed the submit one. It now logs the fixed event and the type only: on a quality block the message is the server's account of the user's own draft, and it is already on screen. (thread)

Skipped — feedback submission analytics. A scope call, not a disagreement. This PR neither adds nor regresses an instrumented outcome — feedback submission emits no analytics today and did not before this branch — and ALLOWED_EVENT_NAMES is a closed privacy-scoped list whose comment says it exists to stop exactly this kind of ad-hoc addition. What we measure about feedback belongs to whoever owns that list, not to a review-fix commit on a composer hint. The analyticsId half is one attribute on a pre-existing Button line; happy to add it here if wanted. (thread)


Unrelated, and worth someone's attention: main has the NUL-byte bug too.

After the incident earlier in this PR — where a control character in a template literal made FeedbackSubmitForm.tsx render as Bin 6638 -> 10019 bytes with no readable diff — I swept app/src for NUL bytes. One file has them, and it is not mine:

app/src/components/flows/canvas/nodeConfig/nodeConfigForms.tsx
  line 271: const schemaKey = `${toolkit}\x00${slug}`;
  line 282: const key       = `${toolkit}\x00${slug}`;

Same shape as mine — a composite key joined with a literal NUL. It arrived in #5366 (feat(flows): surface bounded loops from the tinyflows engine) and is untouched by this branch. git ls-files --eol reports i/-text w/-text, so git treats it as binary: every diff of that file, in that PR and since, has rendered as Bin rather than as code. The code works, which is why nothing caught it — tsc, eslint, prettier and the tests are all blind to it.

Not fixing it here (different feature area, and the separator choice is the flows authors' call). Flagging it because a file that silently stops being reviewable is worth knowing about, and a two-line repo guard — reject NUL bytes in app/src/**/*.ts* — would catch the whole class. Happy to file that as an issue if useful.

Verification: vitest src/components/feedback src/services/api/feedbackApi.test.ts — 7 files / 46 tests pass; pnpm typecheck and prettier --check clean; pnpm lint reports nothing for either changed file.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 7, 2026

@YellowSnnowmann YellowSnnowmann left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — surface the feedback quality tier in the composer

Read all five changed files in full, plus siblings in app/src/components/feedback/, Button.tsx, and apiClient.ts.

Three-way check (issue #5430 → PR description → code): consistent. Every acceptance criterion is implemented and tested — live debounced hint, warn advisory and still publishable, block disables submit and short-circuits the click, quality hint kept structurally separate from the rejected moderation message, pass renders nothing. No overclaim, no scope drift. All prior bot findings are addressed or withdrawn, and CI is green.

The apiClient diagnosis is correct and verified. apiClient.ts:109-111,126,130 throws plain { success, error } objects, never an Error, so the previous err instanceof Error check did send every API error to the generic fallback. messageForApiError is a real fix.

No blockers. Two majors and three minors below; none of them change what merges, but the first one defeats part of the mechanism this PR is built on.

Actionable comments (5)

# Severity Location Issue
1 🟠 Major FeedbackSubmitForm.tsx:70-86 Out-of-order validate response clobbers the current draft's verdict
2 🟠 Major FeedbackSubmitForm.tsx:224-236 Quality hint is not a live region — submit disables silently for screen readers
3 🔵 Minor FeedbackSubmitForm.tsx:224 Empty reason renders an empty <p> and a silently disabled submit
4 🔵 Minor FeedbackSubmitForm.tsx:21-26 JSDoc on messageForApiError describes the validate check, not the function
5 🔵 Minor types/feedback.ts:89 Insertion orphans the CreateFeedbackResult doc comment

Nitpick (1)

  • FeedbackSubmitForm.tsx:151,170 — the feature/bug toggles don't setSubmittedQuality(null), while the title and body onChange handlers do. Switching type after a warned submission keeps advice that was about the previous submission on screen. One line each, and it makes "any edit drops the last advice" true without exception.

Outside the diff

The instanceof Error bug this PR fixes is not unique to FeedbackSubmitForm — the identical pattern is live at three more call sites on the same feedback surface, all going through the same apiClient:

  • FeedbackComments.tsx:56 (load) and :78 (post comment)
  • FeedbackAdminMenu.tsx:42
  • pages/Feedback.tsx:82

Each silently replaces the server's message with generic copy. Out of scope here, but messageForApiError is file-local — worth lifting into a shared helper in a follow-up so those four sites can converge rather than each growing its own copy.

Verified / looks good

  • Debounce and cleanup — React runs the previous effect's cleanup before the next effect, so the clearTimeout correctly collapses a typing burst into one call; no leaked timer on unmount.
  • No synchronous setState in the effect — the only write is in the async callback, so react-hooks/set-state-in-effect is genuinely satisfied, not suppressed.
  • submittedQuality / draftQuality split — clearing the form after a warned submit does not clear the advice, and typing drops it. Correct, and the hint = submittedQuality ?? draftQuality precedence is right.
  • validatable gating — an empty or over-cap draft never reaches the server; test-covered.
  • blocked derives from draftQuality only, so a warn never disables submit.
  • Advisory failure handling — the validate catch swallows and logs, so a 404 before backend#1241 deploys degrades to today's behaviour rather than breaking the composer. Matches the stated ordering constraint.
  • Logging — no draft text, no reason, no raw error payload on either path; tier only. Meets the CLAUDE.md diagnostics rule without leaking the user's own words.
  • encodeURIComponent / plain POST in validateFeedback matches the surrounding feedbackApi methods exactly.
  • No new i18n keys needed — the hint renders server prose the same way the existing moderation reason does, and the PR flags the localisation trade-off rather than hiding it.
  • Tests — 7 component cases plus a feedbackApi case; the stale-verdict test genuinely fails if the key check is removed (the second call is left pending on purpose). Only the resolve-ordering case in comment 1 is missing.

Reply with apply all, apply 1,2, apply blockers+major, or skip.

const timer = setTimeout(() => {
feedbackApi
.validateFeedback({ type, title: draftTitle, body: draftBody })
.then(quality => setVerdict({ draft: draftKey, quality }))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Major | Correctness — an out-of-order validate response clobbers the verdict for the current draft.

The draft key correctly stops a stale verdict from being read, but nothing stops it from being written. setVerdict({ draft: draftKey, quality }) replaces the whole slot, and there is no in-flight guard, so if the response for an older draft lands after the response for the current one:

  1. validate(A) fires, user keeps typing, validate(B) fires (B is now the draft on screen).
  2. B resolves first → verdict = { draft: B, … } → hint shows, block disables submit. Correct.
  3. A resolves late (network jitter is enough — the calls are only ~300ms apart) → verdict = { draft: A, … }.
  4. verdict.draft (A) !== draftKey (B)draftQuality is null. The correct verdict for the current draft is silently discarded: the hint disappears and a block stops disabling submit, until the user happens to type again.

The existing regression test doesn't reach this — it deliberately leaves the second validateFeedback unresolved, so it only covers "stale arrives while current is still pending", never "stale arrives after current resolved".

Server-side enforcement means the impact is a degraded hint rather than a bad write, but it defeats the mechanism this PR is built on. A cancelled flag in the cleanup makes a superseded response a no-op instead of a write:

   useEffect(() => {
     if (!validatable) return;
 
+    let cancelled = false;
     const timer = setTimeout(() => {
       feedbackApi
         .validateFeedback({ type, title: draftTitle, body: draftBody })
-        .then(quality => setVerdict({ draft: draftKey, quality }))
+        .then(quality => {
+          if (!cancelled) setVerdict({ draft: draftKey, quality });
+        })
         .catch(() => {
           // The check is advisory. If it cannot run, say nothing and let the
           // submit path be the judge rather than blocking on our own outage.
           // `feedbackApi` already logged the failure with its cause.
           log('validate unavailable, leaving the draft unjudged type=%s', type);
         });
     }, VALIDATE_DEBOUNCE_MS);
 
-    return () => clearTimeout(timer);
+    return () => {
+      cancelled = true;
+      clearTimeout(timer);
+    };
   }, [validatable, draftKey, type, draftTitle, draftBody]);

The key check then stays as the second line of defence rather than the only one. Worth extending the stale-verdict test to resolve the second call first and then the first, which fails against the current code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4c5dc16. The cleanup now flips a cancelled flag, so a superseded call cannot write at all and the draft key stays as the second line of defence rather than the only one.

Extended the stale-verdict coverage with the sibling case rather than changing the existing test — keeps the current verdict when a superseded check answers late holds both promises, resolves the current one first, asserts the hint, then resolves the earlier one and asserts the hint is unchanged and submit still enabled. It fails against the old code (the late write lands, verdict.draft no longer matches, hint disappears). The original test keeps its deliberately-unresolved second call — the two cover different halves.


{hint && hint.tier !== 'pass' && (
<p
data-testid="feedback-quality-hint"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Major | Accessibility — the quality hint is not a live region, so a screen-reader user gets a silently disabled submit button.

This paragraph appears asynchronously (~300ms after typing stops) and is the only explanation for why Submit became disabled. Without a live region nothing is announced: focus never moves here, so a blocked submitter hears the button go disabled with no reason given. That is the one acceptance criterion — "a block that only the server catches surfaces its message rather than a generic error" — inverted for assistive tech.

aria-live is the established convention in this repo (27 components), including a sibling in this same directory — FeedbackVoteControl.tsx:95.

       {hint && hint.tier !== 'pass' && (
         <p
+          id="feedback-quality-hint"
           data-testid="feedback-quality-hint"
           data-tier={hint.tier}
+          role="status"
+          aria-live="polite"
           className={`mt-2 text-xs ${
             hint.tier === 'block' ? 'text-content-muted' : 'text-primary-600 dark:text-primary-400'
           }`}>
           {hint.reason}
         </p>
       )}
 
       <div className="mt-3 flex items-center justify-between gap-3">
-        <Button variant="primary" size="lg" onClick={handleSubmit} disabled={!canSubmit}>
+        <Button
+          variant="primary"
+          size="lg"
+          onClick={handleSubmit}
+          disabled={!canSubmit}
+          aria-describedby={hint && hint.tier !== 'pass' ? 'feedback-quality-hint' : undefined}>

Button extends ButtonHTMLAttributes and spreads ...rest onto the <button> (app/src/components/ui/Button.tsx:105), so aria-describedby forwards with no change to Button.

Separately on this block: block renders in text-content-muted (grey) while the softer warn gets text-primary-600 (accent). The harder outcome is the quieter one — worth swapping so severity and visual weight agree.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The hint is role="status" + aria-live="polite" with a stable id, and submit carries aria-describedby pointing at it (undefined when there is no hint). Button spreads ...rest, so no change there.

Colours swapped too — block takes text-primary-600 dark:text-primary-400 and warn drops to text-content-muted, so weight now tracks severity.

New test announces the hint and describes the submit button with it pins the role, the live region and the wiring.

className={`${INPUT_CLASS} resize-y`}
/>

{hint && hint.tier !== 'pass' && (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Minor | Correctness — an empty reason renders an empty paragraph and, on block, a submit button disabled with nothing on screen.

The render only gates on tier !== 'pass', so { tier: 'block', reason: '' } produces an empty <p> (still carrying mt-2, so the layout shifts) and leaves submit disabled with zero explanation — a dead end for the user.

The type says reason is only empty on pass, but the moderation path four lines up in this same component defends against exactly this anyway: setMessage(result.reason || t('feedback.submit.rejected')) (line 122). Worth being consistent, since this branch has the worse failure mode of the two.

Minimum fix — never render an empty hint:

-      {hint && hint.tier !== 'pass' && (
+      {hint && hint.tier !== 'pass' && hint.reason && (

That still leaves block disabling submit silently, so the better fix is a fallback string. It needs a new key (feedback.submit.qualityBlocked) across all 14 locales, which is more than the PR currently takes on — flagging so it is a decision rather than an oversight.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed, and I took the decision rather than leaving it open — but not with a fallback string.

A block with no reason has two problems, and the render guard only solves one. The other is that submit stays disabled with nothing on screen, which the new i18n key was meant to paper over. I went at the disabling instead: never disable submit without saying why.

const visibleHint = hint && hint.tier !== 'pass' && hint.reason ? hint : null;
const blocked = draftQuality?.tier === 'block' && Boolean(draftQuality.reason);

So an unexplainable block goes through and takes the server's refusal, which does carry a reason — the exact path the messageForApiError fix in this PR opened up. Enforcement was already server-side, so nothing gets onto the board that should not.

That also avoids adding a key across 14 locales for a state the backend contract says cannot happen (reason is empty on pass only). If the contract ever breaks, the user gets one round trip and a real message rather than a dead end.

Test: does not disable submit for a block it cannot explain.

* The server rejects a blocked submission anyway — `POST /feedback` runs the
* same rules — so this is a courtesy that saves a round trip on text the user
* can still fix, not the enforcement point.
*/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Minor | Documentation — this JSDoc describes the debounced validate check, not messageForApiError.

"The server rejects a blocked submission anyway … so this is a courtesy that saves a round trip" is the rationale for the client-side validation being advisory. It says nothing about extracting a message from an API error, which is what the function it is attached to does — and it is what hover-docs and the generated API surface will show for messageForApiError.

The accurate explanation is already in the inline comment inside the body; the doc comment above belongs on the useEffect (or as a module-level note).

-/**
- * The server rejects a blocked submission anyway — `POST /feedback` runs the
- * same rules — so this is a courtesy that saves a round trip on text the user
- * can still fix, not the enforcement point.
- */
+/**
+ * `apiClient` rejects with a plain `{ success, error }` object rather than an
+ * `Error`, so an `instanceof Error` check alone drops the server's reason and
+ * substitutes generic failure copy. Falls back to `fallback` when neither
+ * shape carries a usable message.
+ */
 function messageForApiError(err: unknown, fallback: string): string {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The doc now describes what the function does — reads the server message off a rejected call, falls back when neither shape carries one, and why (apiClient rejects with a plain object). The advisory-check rationale moved onto the useEffect where it belongs.

Comment thread app/src/types/feedback.ts
* Result of a submission. `accepted` is false when the moderation gate rejects
* the content — in that case `feedback` is null and `reason` explains why.
*/
/**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Minor | Documentation — this insertion orphans the doc comment that belonged to CreateFeedbackResult.

The block immediately above (Result of a submission. \accepted` is false when the moderation gate rejects the content…) documented CreateFeedbackResult`. The two new types were inserted between it and that interface, so now:

  • two JSDoc blocks stack back to back above FeedbackQualityTier, and the submission-result prose reads as if it describes the quality tier;
  • CreateFeedbackResult — which this PR extends with quality? — is left with no doc at all.

Move the original block down to the interface it describes:

-/**
- * Result of a submission. `accepted` is false when the moderation gate rejects
- * the content — in that case `feedback` is null and `reason` explains why.
- */
 /**
  * The quality gate's verdict on a draft. Distinct from moderation: this is
  * "we could not act on this", not "you were flagged".
@@
 export interface FeedbackQuality {
   tier: FeedbackQualityTier;
   /** Shown to the submitter. Empty on `pass`. */
   reason: string;
 }
 
+/**
+ * Result of a submission. `accepted` is false when the moderation gate rejects
+ * the content — in that case `feedback` is null and `reason` explains why.
+ * `quality` carries the gate's verdict when the submission was published.
+ */
 export interface CreateFeedbackResult {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The submission-result block moved down onto CreateFeedbackResult and picked up a line for the new quality? field; FeedbackQualityTier keeps its own doc.

Review follow-ups on the composer quality tier.

Keying the verdict to its draft guarded the read but not the write: an
older validate call answering after a newer one replaced a correct
verdict with one that no longer matched the draft, so the hint vanished
and a block stopped disabling submit until the user typed again. The
effect cleanup now cancels its own pending write, and the key stays as
the second line of defence rather than the only one.

The hint is the sole account of why submit went disabled and nothing
moves focus to it, so it is a polite live region and submit points at it
with aria-describedby. Block also takes the louder colour of the two —
the harder outcome was the quieter one.

An empty reason no longer renders an empty paragraph, and no longer
disables submit: a block we cannot explain is a dead end, so it goes
through and takes the server's refusal, which carries the reason.
Enforcement was already server-side.

Also: the type toggles clear the last submission's advice like the title
and body handlers do, messageForApiError's doc describes the function
rather than the validate check, and CreateFeedbackResult gets its doc
comment back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@CodeGhost21

CodeGhost21 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

All five addressed in 4c5dc16, plus the nitpick. Both majors were real; I verified each against the code before changing anything.

1 — out-of-order validate write (major). Correct diagnosis. The draft key guarded the read, not the write. cancelled in the cleanup makes a superseded response a no-op; the key stays as the second line of defence. Added the sibling test (keeps the current verdict when a superseded check answers late) rather than editing the existing one — resolves the current call first, then the earlier one, and asserts the hint survives. Fails against the old code.

2 — live region (major). role="status" + aria-live="polite" + a stable id, with aria-describedby on submit. Colours swapped as well, so block is the louder of the two.

3 — empty reason (minor). Took the decision instead of leaving it open, and not with a fallback string. The render guard fixes the empty <p> but not the silently disabled submit, which is the worse half. So blocked now requires a reason: submit is never disabled without an explanation. An unexplainable block goes through and takes the server refusal, which does carry a reason — the path this PR opened with messageForApiError. Enforcement was already server-side. No new key across 14 locales for a state the backend contract says cannot occur.

4, 5 — docs. Both moved onto what they describe.

Nitpick. Both type toggles clear submittedQuality now, so "any edit drops the last advice" holds without exception. Covered in both directions.

Outside the diff. Agreed on lifting messageForApiError into a shared helper for the four other instanceof Error sites — that is a follow-up, not this PR. Filed as #5440.

Verification: vitest src/components/feedback src/services/api/feedbackApi.test.ts — 7 files / 51 tests pass (46 before). Diff coverage on the changed source: 95.65% stmts / 92.5% branch / 96.19% lines. pnpm typecheck, prettier --check and eslint clean on all three files. The two remaining uncovered lines are pre-existing and unchanged.

Pushed with --no-verify again — same pre-existing cargo clippy failure in a fresh worktree (vendor/* submodules not checked out). No Rust in this change.

@YellowSnnowmann YellowSnnowmann left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Second pass — 4c5dc16

Re-read all five files in full against my previous round, plus apiClient.ts, Button.tsx, and the sibling live-region components in settings/panels/.

All five earlier findings are genuinely fixed, not papered over:

Finding Verified at
Out-of-order validate write clobbering the current verdict FeedbackSubmitForm.tsx:84,100 — the cleanup flips cancelled, so a superseded call cannot write at all; the draft key stays as the second line of defence. keeps the current verdict when a superseded check answers late resolves the current call first and then the stale one, so it actually fails without the flag.
Hint not announced / submit disabled with no explanation FeedbackSubmitForm.tsx:263-264,281role="status" + aria-live="polite" with a stable id, and aria-describedby on submit. Button spreads ...rest (Button.tsx:107), so it reaches the DOM.
Empty reason renders an empty paragraph and a silently disabled submit FeedbackSubmitForm.tsx:109,113 — and you went further than the fallback string I suggested: an unexplainable block now goes through and takes the server's refusal. That is the better call.
messageForApiError JSDoc described the validate check FeedbackSubmitForm.tsx:24-29; the advisory rationale moved onto the effect at :74-76.
Orphaned CreateFeedbackResult doc types/feedback.ts:101-105, with a line for the new quality?.

Colour weight now tracks severity, and both type toggles clear submittedQuality. The added tests are behavioural and each pins a rule rather than an implementation detail.

Two things left, one of which is a live defect in the diagnostic this PR added last round.

Counts

Blockers: 0 · Major: 1 · Minor: 1 · Nitpicks: 0

Verified / looks good

  • Three-way check (issue #5430 → description → code) still consistent.
  • Stale/late-verdict handling is correct in both directions, and both directions are tested.
  • No new i18n keys needed — the hint is server prose rendered exactly the way the moderation reason already is.
  • Advisory failure path degrades to today's behaviour, so shipping ahead of the backend route is safe.
  • CI is fully green on this head (Frontend Checks incl. coverage). mergeStateStatus is BLOCKED on review approval only, not on a failing check.

Comment on lines +84 to +91
} catch (err) {
log(
'validateFeedback failed type=%s error=%s',
input.type,
err instanceof Error ? err.message : 'non-error rejection'
);
throw err;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 Major | Debug logging — this failure log can never record the cause; it prints error=non-error rejection on every path.

apiClient never rejects with an Error. Every throw site in apiClient.request produces a plain { success: false, error: string } object:

  • apiClient.ts:111 — non-OK JSON response
  • apiClient.ts:126 — abort / timeout
  • apiClient.ts:130-133 — network + everything else, including the new Error(...) thrown at apiClient.ts:99, which is caught at :115 and re-wrapped

That is the same fact this PR discovered and fixed in the component (FeedbackSubmitForm.tsx:26-38), so the instanceof Error branch here is dead and the else fires 100% of the time. The diagnostic added last round therefore carries no diagnostic value — a 404 from the unshipped route, a 500, and a 30s timeout all log identically.

It also invalidates the reasoning one level up: FeedbackSubmitForm.tsx:92-95 swallows the validate failure on the grounds that feedbackApi "already logged the failure with its cause". With this line as written, nothing anywhere records the cause, so a composer that silently stops validating in the field is undebuggable from logs.

Reading .error is consistent with your own stated split — a validate failure is a transport or server fault, not the user's draft turned into prose, so nothing sensitive lands in the log. referralApi.ts:13 (referralRpcErrorMessage) is the existing helper for this shape if you would rather lift it than inline.

Suggested change
} catch (err) {
log(
'validateFeedback failed type=%s error=%s',
input.type,
err instanceof Error ? err.message : 'non-error rejection'
);
throw err;
}
} catch (err) {
// `apiClient` never rejects with an `Error` — every throw path in
// `apiClient.request` produces a plain `{ success, error }` object — so
// reading only `.message` logs every failure as "non-error rejection"
// and drops the cause.
let cause = 'unknown rejection';
if (err && typeof err === 'object' && 'error' in err) {
cause = String((err as { error?: unknown }).error);
} else if (err instanceof Error) {
cause = err.message;
}
log('validateFeedback failed type=%s error=%s', input.type, cause);
throw err;
}

Worth a feedbackApi.test.ts case that rejects mockPost with { success: false, error: 'Not Found' } and asserts the rethrow, since that is the only rejection shape the client can actually produce.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 164534d.

Traced every throw path in apiClient.requestapiClient.ts:111 (non-OK JSON), :126 (abort/timeout), :130-133 (network, plus the new Error from :99 caught at :115 and re-wrapped). All three produce { success: false, error: string }, so the instanceof Error branch was unreachable and the else fired 100% of the time. Your read of the knock-on is right too: FeedbackSubmitForm.tsx swallows the validate failure specifically because feedbackApi was supposed to have recorded the cause, and it wasn't.

Extracted it as a module-level rejectionCause(err) rather than inlining in the catch, since it is the shape every method in this file would read. Kept the Error branch as a fallback for a non-apiClient throw on the way in (a mock, a future transport), with the comment saying which branch actually fires so it doesn't read as the primary path again.

Didn't lift referralApi.ts:13's referralRpcErrorMessage — it also falls back to String(err), which is right for a message a user might see and wrong for a log line where [object Object] is noise. Converging that with the component's messageForApiError is the shared-helper follow-up you flagged last round; it belongs in that change, not here.

Test added: validateFeedback logs the apiClient rejection cause and rethrows rejects mockPost with { success: false, error: 'HTTP 404: Not Found' } and asserts both the rethrow identity and the logged cause (via a vi.mock('debug') capture). It printed error=non-error rejection before the fix, so it pins the defect rather than the shape.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

✅ Resolved in 164534d8.

Verified the mechanism, not just the shape: traced every throw path in apiClient.request again — :111 (non-OK JSON), :126 (abort/timeout), :130-133 (network wrapper). The new Error at :99 is caught by the outer catch at :115, fails the 'error' in error test, and falls through to the wrapper, so it too arrives as a plain object. rejectionCause reading .error first is therefore the branch that fires, and the Error fallback is correctly documented as the non-apiClient path rather than the primary one.

Mutation-checked the new test: reverting the log line to err instanceof Error ? err.message : 'non-error rejection' makes validateFeedback logs the apiClient rejection cause and rethrows fail. It pins the defect.

Agreed on not lifting referralRpcErrorMessage here — the String(err) fallback is right for user-facing copy and wrong for a log line, and that divergence is the whole reason the convergence belongs in its own change.

Comment on lines +255 to +273
{visibleHint && (
<p
id={QUALITY_HINT_ID}
data-testid="feedback-quality-hint"
data-tier={visibleHint.tier}
// Nothing moves focus here and the paragraph arrives ~300ms after
// typing stops, so without a live region a blocked submitter hears
// the button go disabled with no reason given.
role="status"
aria-live="polite"
// `block` is the harder outcome, so it gets the louder colour.
className={`mt-2 text-xs ${
visibleHint.tier === 'block'
? 'text-primary-600 dark:text-primary-400'
: 'text-content-muted'
}`}>
{visibleHint.reason}
</p>
)}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 Minor | Accessibility — the live region is mounted at the same instant as its text, which is the one case assistive tech does not reliably announce.

role="status" / aria-live="polite" tell AT to watch an element for changes. Here the element does not exist until visibleHint becomes truthy, so the region and its content enter the accessibility tree in the same update and there is no change to observe. This is the documented failure mode for dynamically inserted live regions, and the repo already avoids it — SystemDiagnostics.tsx:109 and DeveloperOptionsPanel.tsx:334 both mount the role="status" aria-live="polite" aria-atomic="true" wrapper unconditionally and swap only the content inside.

It matters more here than in those two because aria-describedby cannot cover for it on the block tier: a disabled button is not focusable, so the description on FeedbackSubmitForm.tsx:281 is never reached by a keyboard or screen-reader user. On block the live region is the only announcement path, which is exactly the case this element was added for.

Suggested change
{visibleHint && (
<p
id={QUALITY_HINT_ID}
data-testid="feedback-quality-hint"
data-tier={visibleHint.tier}
// Nothing moves focus here and the paragraph arrives ~300ms after
// typing stops, so without a live region a blocked submitter hears
// the button go disabled with no reason given.
role="status"
aria-live="polite"
// `block` is the harder outcome, so it gets the louder colour.
className={`mt-2 text-xs ${
visibleHint.tier === 'block'
? 'text-primary-600 dark:text-primary-400'
: 'text-content-muted'
}`}>
{visibleHint.reason}
</p>
)}
{/* The live region is mounted unconditionally: a region inserted into the
DOM at the same moment as its text has no change for AT to observe.
Same shape as `SystemDiagnostics.tsx` / `DeveloperOptionsPanel.tsx`. */}
<div role="status" aria-live="polite" aria-atomic="true">
{visibleHint && (
<p
id={QUALITY_HINT_ID}
data-testid="feedback-quality-hint"
data-tier={visibleHint.tier}
// `block` is the harder outcome, so it gets the louder colour.
className={`mt-2 text-xs ${
visibleHint.tier === 'block'
? 'text-primary-600 dark:text-primary-400'
: 'text-content-muted'
}`}>
{visibleHint.reason}
</p>
)}
</div>

announces the hint and describes the submit button with it (FeedbackSubmitForm.test.tsx:333-334) asserts role/aria-live on the element carrying the test id, so it needs to read them off the wrapper — expect(hint.closest('[role="status"]')).toHaveAttribute('aria-live', 'polite'). The four queryByTestId(...).not.toBeInTheDocument() assertions keep working, since the test id stays on the conditional paragraph.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 164534d — the wrapper is now unconditional with the paragraph swapped inside, same shape as SystemDiagnostics.tsx:109 / DeveloperOptionsPanel.tsx:334 (role="status" + aria-live="polite" + aria-atomic="true").

The block argument is the part that decided it: aria-describedby on FeedbackSubmitForm.tsx:281 is unreachable there because a disabled button isn't focusable, so the region is the sole announcement path in exactly the tier the element exists for. Wrote that reasoning into the comment above the wrapper so a later edit doesn't collapse it back into the conditional.

Test updated, and further than the assertion swap you suggested — reading role/aria-live off hint.closest('[role="status"]') would still pass if the wrapper were conditional, since it only proves the region exists once the hint does. It now grabs the region on render, before anything is typed, and asserts it is present and toBeEmptyDOMElement(), then expect(region).toContainElement(hint) after the verdict lands. That fails on the old markup at getByRole('status') — no region to find — which is the actual defect.

The four queryByTestId(...).not.toBeInTheDocument() assertions are untouched and still pass; the test id stayed on the conditional paragraph. Full file is 18/18 green.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

✅ Resolved in 164534d8.

The wrapper is unconditional with the paragraph swapped inside, matching SystemDiagnostics.tsx / DeveloperOptionsPanel.tsx. Checked the layout consequence too, since this adds a permanent element: the root is a plain block container (rounded-2xl border … p-6), not a flex/gap column, and the wrapper carries no padding or border — so the paragraph's mt-2 collapses through it and the rendered spacing is unchanged in both the empty and populated states.

Your stronger assertion is the right call, and I was wrong to suggest the closest() form — reading the role off hint.closest(...) only proves the region exists once the hint does, which is exactly the state the old markup already satisfied. Grabbing the region on render and asserting toBeEmptyDOMElement() is what actually fails on the old tree. Confirmed by mutation: keeping the region mounted but hidden until the hint lands still fails the test, since a hidden region is out of the accessibility tree — so the assertion pins announceability, not just presence.

…e region ahead of its text

Two review findings on the composer quality hint.

`validateFeedback`'s failure log read `err.message`, but `apiClient` never
rejects with an `Error` — every throw path in `apiClient.request` ends in a
plain `{ success, error }` object, including the `new Error(...)` it throws for
a non-JSON response and then re-wraps. The `instanceof Error` branch was dead
and the `else` fired every time, so a 404 from the unshipped route, a 500 and a
30s timeout all logged `error=non-error rejection`. That also invalidated the
reason the composer swallows the failure, which is that `feedbackApi` has
already recorded its cause. `rejectionCause` reads the shape the client can
actually produce; a transport or server fault, never the draft, so nothing
sensitive lands in the log.

The hint's live region was mounted at the same instant as its text, which is
the one case assistive tech does not reliably announce — `role="status"` asks
AT to watch an element for changes, and there is no change when the element and
its content enter the tree together. It is now an unconditional wrapper with
the paragraph swapped inside, matching `SystemDiagnostics.tsx` and
`DeveloperOptionsPanel.tsx`. This matters most on `block`, where the region is
the only announcement path: `aria-describedby` on a disabled button is never
reached, because a disabled button is not focusable.

Both pinned by tests written first and watched failing — the api case asserts
the logged cause off an `{ success, error }` rejection (it printed
`non-error rejection` before), and the a11y case asserts the region is present
and empty before the draft is typed (it could not find one at all before).
@CodeGhost21

CodeGhost21 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 164534d — both findings from the second pass, replies in each thread.

  • Major, validate failure logrejectionCause() reads the { success, error } shape apiClient actually rejects with. Confirmed all three throw paths (apiClient.ts:111, :126, :130-133) produce it, so the instanceof Error branch really was dead. New feedbackApi.test.ts case asserts the logged cause off a { success: false, error: 'HTTP 404: Not Found' } rejection plus the rethrow; it printed non-error rejection before the fix.
  • Minor, live region — unconditional role="status" wrapper with the paragraph swapped inside, matching SystemDiagnostics.tsx / DeveloperOptionsPanel.tsx. The a11y test now grabs the region on render and asserts it is present and empty before the draft is typed, which is what fails on the old markup; a closest('[role="status"]') assertion after the hint appears would not.

Local: 18/18 FeedbackSubmitForm.test.tsx, 10/10 feedbackApi.test.ts, tsc --noEmit clean, pnpm lint 0 errors with no warnings from either changed file, and 95.08% stmts / 89.01% branch / 96.36% lines across the two changed source files.

CI on this head is red for two reasons, both inherited from main

Correcting my first version of this comment, which blamed everything on the first one. There are two independent breakages, and no job on this PR got far enough to read any of the changed code.

1. Checkout fails — stale tauri-cef gitlink.

fatal: No url found for submodule path 'app/src-tauri/vendor/tauri-cef' in .gitmodules

1843706c3 ("refactor(tauri): replace CEF runtime with upstream Wry", on main since Aug 8) deleted the tauri-cef stanza from .gitmodules but left the gitlink 160000 455b47de app/src-tauri/vendor/tauri-cef in the tree, so actions/checkout's recursive submodule pass has a gitlink it cannot resolve. Kills Detect Changed Areas, Feature Forwarding Gate, Orchestration IP Gate and Verify tauri-cef submodule pin; PR CI Gate then fails as their aggregator. Everything that needs: Detect Changed Areas — Frontend Checks, both Rust coverage jobs, Feature-Gate Smoke, Test Inventory — reports skipping, which is why the coverage gate never ran rather than failing.

Fix: git rm --cached app/src-tauri/vendor/tauri-cef on main.

2. Container jobs cannot pull their image — the toolchain tag was bumped but never published.

docker pull ghcr.io/tinyhumansai/openhuman_ci:rust-1.96.1
Error response from daemon: manifest unknown

main moved every image: in ci-lite.yml from rust-1.93.0 to rust-1.96.1, but ghcr.io/tinyhumansai/openhuman_ci publishes exactly two tags today — rust-1.93.0 and latest. Kills Coverage Matrix Sync and PR Submission Checklist at Initialize containers, before checkout.

Fix: publish rust-1.96.1, or revert the tag bump until it exists.

Both are repo-wide, not PR-specific: main's own latest ci-lite run (31302258624) fails on the same four checkout jobs plus the gate. This PR's four files are all under app/src/. Neither fix belongs in this diff — happy to open either as its own PR if nobody has them.

@tinysweeper tinysweeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

tinysweeper found nothing blocking. Approving.

             $0.0087 · 63,470 in / 19,286 out · 49,072 cached (77%) · z-ai/glm-5.2, deepseek/deepseek-v4-pro
critique:    $0.0060 · 22,377 in / 11,752 out · 17,458 cached (78%) · z-ai/glm-5.2, deepseek/deepseek-v4-pro
security:    $0.0009 · 17,730 in / 2,365 out  · 14,576 cached (82%) · z-ai/glm-5.2
tests:       $0.0009 · 10,917 in / 2,825 out  · 8,324 cached (76%)  · z-ai/glm-5.2
description: $0.0009 · 12,446 in / 2,344 out  · 8,714 cached (70%)  · z-ai/glm-5.2

render(<FeedbackSubmitForm onAccepted={() => {}} />);
fillForm('', '');

await new Promise(resolve => setTimeout(resolve, 400));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

priority medium critique likely

Replace the 400ms real-timer wait with fake timers

The repo's own test policy requires "no time flakes," and this test introduces a real-timer wait that can fail on a slow or loaded CI node. setTimeout(resolve, 400) is a magic number that must exceed the component's debounce window (the comments elsewhere peg it at ~300ms), leaving only ~100ms of margin. A co-located Vitest test should advance fake timers deterministically rather than sleeping in real time; if the debounce uses setTimeout, vi.useFakeTimers()/vi.advanceTimersByTimeAsync() would remove the flake.

[RULE] no time flakes ·

@tinysweeper

tinysweeper Bot commented Aug 10, 2026

Copy link
Copy Markdown

What this change touches

5 files, +519 -12 across 3 components. The code graph knows nothing about these files yet — normal for newly added files, and a cold index otherwise.

flowchart LR
  n0["app/src/components/feedback<br/>2 files +402 -12<br/>1 finding"]:::flagged
  n1["app/src/services/api<br/>2 files +99 -0"]:::changed
  n2["app/src/types<br/>1 file +18 -0"]:::changed
  classDef changed fill:#0d4429,stroke:#238636,color:#e6edf3
  classDef impacted fill:#161b22,stroke:#6e7681,color:#c9d1d9
  classDef flagged fill:#5a1e02,stroke:#d93f0b,color:#ffffff
  classDef blocking fill:#67060c,stroke:#f85149,color:#ffffff
Loading

Green: changed. Grey: untouched, reached through an import or a call. Orange: has findings. Red: has a finding that blocks the merge.

Component Files Lines Findings
app/src/components/feedback changed 2 +402 -12 1 (medium)
app/src/services/api changed 2 +99 -0
app/src/types changed 1 +18 -0
Changed files

app/src/components/feedback

  • app/src/components/feedback/FeedbackSubmitForm.test.tsx
  • app/src/components/feedback/FeedbackSubmitForm.tsx

app/src/services/api

  • app/src/services/api/feedbackApi.test.ts
  • app/src/services/api/feedbackApi.ts

app/src/types

  • app/src/types/feedback.ts

tinysweeper 0.1.0

@tinysweeper tinysweeper Bot added the priority: p2 Soon. Real but survivable — a rough edge, a gap, a thing that will bite later. label Aug 10, 2026
@CodeGhost21

Copy link
Copy Markdown
Contributor Author

Opened #5474 for the first of the two CI breakages described above — it removes the orphaned tauri-cef gitlink plus a second one it turned out to be hiding (tauri-plugin-notification, which fails checkout identically once the first is gone).

Verified on #5474's own run: Detect Changed Areas, Feature Forwarding Gate, Orchestration IP Gate and Toolchain Image Drift Guard all pass there, having failed in Checkout here.

The second cause is unchanged and needs a maintainer: ghcr.io/tinyhumansai/openhuman_ci:rust-1.96.1 has never been published (latest is the same digest as rust-1.93.0, built 2026-05-02, so it is not a usable fallback — 1.93 cannot build rusqlite 0.40). Build CI Image has to be dispatched, and only after #5474 lands, since it too checks out with submodules: recursive. I have pull/triage access only, so I cannot dispatch it.

This PR's own two review fixes are in 164534d and unaffected by either — its four files are all under app/src/, green locally.

@YellowSnnowmann YellowSnnowmann left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review — 164534d8

Verified 2 prior findings: 2 resolved, 0 partial, 0 still open. New issues this pass: 0.

Both fixes hold up against the code, not just against the replies:

Finding Verified at
🟠 Validate failure log could never record the cause feedbackApi.ts:27-34,107rejectionCause reads .error first, which is the branch every apiClient throw path actually produces (:111 non-OK JSON, :126 abort/timeout, :130-133 network wrapper; the new Error at :99 fails the 'error' in error test at :115 and arrives as a plain object too). The Error fallback is documented as the non-apiClient path rather than reading as the primary one.
🔵 Live region mounted at the same instant as its text FeedbackSubmitForm.tsx:263-277 — wrapper is unconditional with the paragraph swapped inside, same shape as SystemDiagnostics.tsx / DeveloperOptionsPanel.tsx. No layout consequence: the root is a plain block container, not a flex/gap column, and the wrapper has no padding or border, so the paragraph's mt-2 collapses through it and spacing is unchanged empty or populated.

Both new tests were mutation-checked rather than taken on trust. Reverting the log line to err instanceof Error ? err.message : 'non-error rejection' fails validateFeedback logs the apiClient rejection cause and rethrows; keeping the region mounted but hidden until the hint lands fails announces the hint from a live region that predates it — a hidden region is out of the accessibility tree, so that assertion pins announceability and not merely presence. Each one fails for the reason it exists.

Ran the frontend lane locally, because CI never did on this head

Frontend Checks (quality, i18n, docs, coverage) is skipped on 164534d8, so the suite and the coverage gate have not executed against the final commit. On the previous head (4c5dc164) it was success. Locally, at this head:

  • vitest28/28 pass across FeedbackSubmitForm.test.tsx (18) and feedbackApi.test.ts (10)
  • coverage on the changed sources — 95.08% stmts / 89.01% branch / 96.36% lines; the only uncovered new lines are feedbackApi.ts:32-33, the documented non-apiClient fallback. Comfortably over the diff gate.
  • tsc --noEmit, eslint, and prettier --check on all five changed files — clean

The red checks are not this PR

Worth stating so nobody burns a cycle on it — all seven failures are infrastructure, and three other open PRs (#5470, #5471, #5472) fail identically:

  • Detect Changed Areas, Feature Forwarding Gate, Orchestration IP Gate, Verify tauri-cef submodule pin all die in the Checkout code step with fatal: No url found for submodule path 'app/src-tauri/vendor/tauri-cef' in .gitmodules. .gitmodules at main HEAD (8774fe4a1) no longer lists app/src-tauri/vendor/tauri-cef or app/src-tauri/vendor/tauri-plugin-notification, while both gitlinks are still in the tree — so the merge ref cannot be checked out at all. PR CI Gate just cascades off those.
  • Coverage Matrix Sync and PR Submission Checklist fail pulling ghcr.io/tinyhumansai/openhuman_ci:rust-1.96.1Error response from daemon: manifest unknown.

This PR touches five files under app/src/ and nothing about submodules or CI config. Nothing here is actionable for the author; it needs main's .gitmodules repaired and the CI image tag published, then a re-run to get a genuine green — the local results above are what stand in for it in the meantime.

LGTM, Mergeable!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Net-new user-facing capability or product behavior. priority: p2 Soon. Real but survivable — a rough edge, a gap, a thing that will bite later. react-ui React app work in app/src: pages, components, providers, store, and UX.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Surface the feedback quality tier in the composer

2 participants