Skip to content

fix(claw-server): keep the feedback invite until it is dismissed - #2767

Merged
Dani Akash (DaniAkash) merged 3 commits into
mainfrom
fix/feedback-invite-persists
Sep 28, 2026
Merged

Dani Akash (DaniAkash) merged 3 commits into
mainfrom
fix/feedback-invite-persists

Conversation

@DaniAkash

Copy link
Copy Markdown
Contributor

The bug

The card appeared once per installation, ever — and then never again, whether or not anyone read it.

The cause was not the click handler. Recording the impression spent the invitation: decide() refused as soon as a row existed, and the row was written the moment the card first painted. Since the cockpit is the new tab page and most new tabs are opened incidentally, the offer was consumed by a paint nobody looked at. That is why reach was so low.

Opening the booking page ended it too, which is a separate mistake: clicking through to a booking page is not the same as having booked, and someone who means to finish later came back to nothing.

The fix

Eligibility now ends only when the reader dismisses it.

consent off            -> not eligible
no install id          -> not eligible
not in the cohort      -> not eligible
dismissed              -> not eligible     <- the only answer that ends it
otherwise              -> eligible

Why dismissal gets its own column

outcome already carries a precedence rule where a click outranks a later dismissal, so that the funnel keeps the strongest claim about what the reader did. Reading "should I stop showing this" off the same field would let the card come back after someone asked it not to.

So the two are separated. outcome stays the funnel's answer; dismissed_at_ms is the reader's instruction to stop, set once and never cleared. A click arriving after a dismissal still raises the outcome to clicked and still leaves the card hidden. A test covers exactly that combination.

Migration m0019 adds the column and backfills it from rows already recorded as dismissed, so nobody who already said no gets asked again. That backfill has its own test, run against a database migrated only as far as m0018.

The impression metric had to change with it

The card now returns on every cockpit load until dismissed. Tracking every appearance would report thousands of impressions for a single reader and leave the funnel without a usable denominator, so feedback_invite_shown is counted once per browser profile.

The split is deliberate: the browser deduplicates the analytics event, while the server is still told about every appearance because it holds the first-seen timestamp, and visibility stays the server's answer rather than the browser's. Losing that local key costs at most a repeated impression, never a missed invitation.

Tests

Server, 14 endpoint tests over the real router and 12 at the database layer:

  • an impression does not stop the card coming back
  • booking does not stop it coming back
  • a dismissal ends it and survives a restart
  • a click after a dismissal does not bring it back, while the funnel still records the click
  • only a dismissal shuts the gate, and a second dismissal does not move its timestamp
  • the gate is backfilled from rows written before it existed

Client, 10 tests driving a real render, including the new one confirming the card still shows on a later load while the impression is counted only once. I checked that test fails when the guard is removed.

cargo fmt, cargo clippy --workspace --all-targets -D warnings, the full claw-server-rust suite and biome all pass.

The card appeared once per installation and never again. Recording the
impression spent the invitation, so the offer was consumed by the first paint of
a new tab whether or not anyone read it, and most of a new tab's openings are
incidental. Almost nobody ever saw it, and opening the booking page ended it
too, which is not the same as having booked.

Eligibility now ends only when the reader dismisses it. Dismissal gets its own
column rather than being read off the outcome, because the two answer different
questions: the outcome is the funnel's strongest claim about what the reader did,
where a click outranks a later dismissal, while this is an instruction to stop
showing the card that nothing outranks. A click arriving after a dismissal still
raises the outcome and still leaves the card hidden.

The migration carries existing dismissals into the new column, so anyone who
already said no is not asked again.

The impression event is now counted once per browser profile. The cockpit is the
new tab page, so tracking every appearance would report thousands of impressions
for one reader and leave the funnel without a usable denominator. The server is
still told each time, since it holds the first-seen timestamp, and visibility
remains its answer rather than the browser's.
@github-actions github-actions Bot added the fix label Sep 28, 2026
@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

[Critical risk] Changes feedback invitation eligibility logic and database schema.

The PR is not yet safe to merge because a failed dismissal write can permanently suppress an invitation the server still considers eligible.

Findings

  1. P1 Failed dismissal hides invitation ▶
  2. P2 Early invitation misses impression ▶
Fix with agent prompt
### Issue 1
packages/browseros-agent/apps/claw-app/components/cockpit/FeedbackInviteCard.tsx:153
If the local service is unavailable long enough for the dismissal POST to fail after its retries, this line has already saved a permanent browser dismissal key. Later loads hide the card even though the server still returns `eligible: true`, and nothing clears the key after failure. The same stale key can hide an eligible invitation if the service recreates its database while browser storage survives.

### Issue 2
packages/browseros-agent/apps/claw-app/components/cockpit/FeedbackInviteCard.tsx:114
If the invitation arrives before analytics is ready, this guard skips the shown event after the card has marked its first appearance. Analytics becoming ready in the same tab does not retry it. A reader who then dismisses the card contributes a dismissal but no impression, making the funnel count less useful; the new test covers only a later load.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR keeps feedback invitations eligible after impressions and booking clicks, adds a durable dismissal gate with migration backfill, and deduplicates browser-profile impression analytics. The new local dismissal marker can, however, hide a server-eligible invitation after a failed write.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Cockpit loads] --> B[Server eligibility]
  B -->|eligible| C[Card appears]
  C --> D[Shown event if capture is ready]
  C --> E[Dismiss]
  E --> F[Browser dismissal key]
  E --> G[Server dismissal POST]
  G -->|success| H[Durable dismissal gate]
  G -->|failure| I[Server remains eligible]
  F --> J[Later card suppressed]
  I --> J
Loading

Reviews (2) · Last reviewed commit: "fix(claw-server): write the dismissal ga..."

Comment thread packages/browseros-agent/apps/claw-app/components/cockpit/FeedbackInviteCard.tsx Outdated
Comment thread packages/browseros-agent/apps/claw-server-rust/src/db/migration.rs
Comment thread packages/browseros-agent/apps/claw-server-rust/src/db/feedback_invite.rs Outdated
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

✅ Tests passed: 2714/2717

Ran 12 of 16 suites (4 not affected by this change).

Suite Passed Failed Skipped
✅ server-agent 216/216 0 0
✅ server-api 279/279 0 0
✅ server-tools 248/248 0 0
✅ server-browser 10/10 0 0
✅ server-integration 10/10 0 0
✅ server-lib 197/197 0 0
✅ server-root 38/41 0 3
✅ agent 372/372 0 0
✅ claw-app 420/420 0 0
⏩ claw-onboard n/a n/a not affected
⏩ app-onboard n/a n/a not affected
⏩ build n/a n/a not affected
⏩ release n/a n/a not affected
✅ claw-server-rust 789/789 0 0
✅ claw-server-rust-quality passed 0 0
✅ claw-mcp 135/135 0 0

passed = ran successfully but emits no JUnit counts (a lint/format gate).

View workflow run

…cross tabs

The funnel outcome and the dismissal gate were two separate statements, so a
crash between them could leave the outcome saying dismissed while the gate stayed
null. Eligibility reads only the gate, so the card came back after a restart with
no migration left to repair it. Both columns now move in one statement, each with
its own guard: the outcome only rises by rank, the gate is only ever set.

Dismissing in one tab left every other open cockpit tab still offering the card
until each reloaded, which the card returning on every load turned from a corner
case into the common one. A dismissal is now remembered locally as well, so the
tabs already open stop showing it. That memory only ever suppresses: the server
stays the authority on who is invited, so losing the key costs one more
appearance and can never reveal an invitation the server refused.

The impression marker was written before knowing the event had anywhere to go.
Analytics readiness is settled by its own request, so the invitation can arrive
first, and marking then dropped the event permanently and left that profile out
of the count. The marker now waits until capture is live.

The migration's choice about older rows is now stated where it is made. A reader
who booked and then closed the card is stored as clicked, because a click
outranks a later dismissal, and the old schema cannot tell them from someone who
booked and never dismissed. Carrying every clicked row across as a dismissal
would permanently silence the most engaged group, so the gap is accepted: it
costs at most one more appearance.
@DaniAkash

Copy link
Copy Markdown
Contributor Author

Greptile (@greptileai)

Comment thread packages/browseros-agent/apps/claw-app/components/cockpit/FeedbackInviteCard.tsx Outdated
Comment thread packages/browseros-agent/apps/claw-app/components/cockpit/FeedbackInviteCard.tsx Outdated
@DaniAkash
Dani Akash (DaniAkash) merged commit 7a5ae53 into main Sep 28, 2026
26 checks passed
@DaniAkash
Dani Akash (DaniAkash) deleted the fix/feedback-invite-persists branch September 28, 2026 08:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant