Skip to content

feat(cat): tagging the Cat now behaves like tagging @grok - #818

Merged
github-actions[bot] merged 2 commits into
mainfrom
feat/cat-reply-arrives
Aug 28, 2026
Merged

feat(cat): tagging the Cat now behaves like tagging @grok#818
github-actions[bot] merged 2 commits into
mainfrom
feat/cat-reply-arrives

Conversation

@catomean

Copy link
Copy Markdown
Collaborator

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 whose parent_event_id is 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.

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.

One definition of "tags the Cat"

activeMentionsCat 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.

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

catomean and others added 2 commits August 28, 2026 20:12
…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
@github-actions
github-actions Bot merged commit bfbd946 into main Aug 28, 2026
8 checks passed
@github-actions
github-actions Bot deleted the feat/cat-reply-arrives branch August 28, 2026 18:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant