feat(cat): tagging the Cat now behaves like tagging @grok - #818
Merged
Conversation
…nt behind neither
Found sweeping the timeline in production. Like a post, then dislike it:
both buttons render as active, and the like count keeps a number that
has no row behind it.
The two reactions are meant to be exclusive and the RPCs enforce it —
liking DELETEs any dislike row, and the reverse. What neither did was
recompute the count belonging to the reaction it had just removed. So:
timeline_likes no row for (event, user) ← correctly deleted
timeline_event_stats like_count = 1 ← never recomputed
dislike_count = 1
That number is wrong for every reader, not just the person who clicked,
and it stays wrong: nothing recomputes these except the next reaction on
the same post. The UI reads timeline_event_stats, so the post shows a
like nobody has given.
All four functions now recount BOTH totals whenever either changes, and
return both. Toggle-off cannot move the opposite count, but returns it
anyway so all four responses have one shape — a caller that has to
remember which of four carries which field will eventually get it wrong.
The existing skewed rows are recounted from the membership tables, which
were right the whole time; only the cache drifted.
DROP + CREATE was forced by the changed return type, so the grants are
restored explicitly rather than left to the default: a reaction that
silently stopped being callable by `authenticated` would look exactly
like the bug being fixed.
The client half was the same mistake from the other side. handleLike
only ever wrote userLiked/likesCount, so it left the opposite button lit
and its count untouched. It now clears the opposite optimistically and
then takes both totals from the response instead of inferring them. A
failed switch restores both sides rather than leaving the post with
neither.
Rehearsed against production inside a transaction and rolled back:
like → (1,0), dislike → (0,1), rows and cached stats agree, and the
repair statement corrected one already-skewed row.
Proven by mutation: dropping the opposite-clearing fails the
switch-sides test and only that one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5
Tagging the Cat under a post already did the right thing where it counts: its answer is written as a child of the post that tagged it, with the thread as context — the same shape as tagging @grok. Verified in production 2026-08-28: a reply "@cat what is this" (52cc045c) got an answer whose parent_event_id is that reply, not the root post. What was missing is that the answer never ARRIVED. The thread refetches once when your reply is created; the Cat takes 10–17 seconds to think (measured: 07:48:46 → 07:49:03, and 07:23:52 → 07:24:02); there is no realtime subscription on timeline events. So the reply you asked for sat in the database until you happened to reload the page. Ask, nothing, and the natural response to nothing is to ask again. useAwaitCatReply waits for it. Polling rather than realtime on purpose: this is a single row, expected once, inside a known short window — a channel to authorise, keep alive and tear down is the wrong instrument for something that ends in under a minute. Whether a reply tags the Cat is decided by activeMentionsCat, which goes through parseMentionCandidates — the same parser the resolver uses to decide whether the Cat actually answers. A second opinion here is how `@catalogue` would start spawning waits for an answer that is never coming. This codebase has had three definitions of an @handle before, and the renderer linked one person while the resolver notified another. The wait is visible. Ten to seventeen seconds of nothing reads as nothing happening, so a row says the Cat is answering, shaped like the reply that will replace it so the thread does not jump. It gives up after a minute rather than spinning forever: the Cat answers even when it cannot think — a fallback reply is still a reply — so silence past that means something upstream is wrong. TimelineComposer now hands the created post to onPostCreated. Callers that only need "something was posted" ignore it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5
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.
What was already right
Tagging the Cat under a post writes its answer as a child of the post that tagged it, with the thread as context — the same shape as
@grok. Verified in production: a reply@cat what is this(52cc045c) got an answer whoseparent_event_idis that reply, not the root post.What was missing
The answer never arrived. The thread refetches once when your reply is created; the Cat takes 10–17 seconds to think (measured: 07:48:46 → 07:49:03, and 07:23:52 → 07:24:02); there is no realtime subscription on timeline events. So the reply you asked for sat in the database until you happened to reload. Ask, nothing — and the natural response to nothing is to ask again.
useAwaitCatReplywaits for it. Polling rather than realtime on purpose: this is a single row, expected once, inside a known short window. A channel to authorise, keep alive and tear down is the wrong instrument for something that ends in under a minute.One definition of "tags the Cat"
activeMentionsCatgoes throughparseMentionCandidates— the same parser the resolver uses to decide whether the Cat actually answers. A second opinion here is how@cataloguewould start spawning waits for an answer that is never coming. This codebase has had three definitions of an@handlebefore, and the renderer linked one person while the resolver notified another.The wait is visible
Ten to seventeen seconds of nothing reads as nothing happening, so a row says the Cat is answering — shaped like the reply that will replace it, so the thread does not jump. It gives up after a minute rather than spinning forever: the Cat answers even when it cannot think (a fallback reply is still a reply), so silence past that means something upstream is wrong.
Six tests, including the two that matter most: it waits when tagged, and does not wait when it wasn't.
🤖 Generated with Claude Code
https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5