Skip to content

fix(labeled-response): inherit batch label onto inner messages#211

Merged
ValwareIRC merged 4 commits into
mainfrom
fix-labeled-response-batch-label
May 13, 2026
Merged

fix(labeled-response): inherit batch label onto inner messages#211
ValwareIRC merged 4 commits into
mainfrom
fix-labeled-response-batch-label

Conversation

@ValwareIRC
Copy link
Copy Markdown
Contributor

@ValwareIRC ValwareIRC commented May 13, 2026

Summary

  • When the server emits >1 line in reply to a tagged command (e.g. PRIVMSG to an /AWAY user, which produces both 301 RPL_AWAY and the echo), the IRCv3 labeled-response spec wraps them in a batch. The label tag lives on the BATCH opener; inner messages only carry @batch=ID.
  • Our pending-message dedup looked for mtags?.label directly on the PRIVMSG echo, so it missed the batched case. The optimistic placeholder timed out into the crossed-out failure state and the server's echo rendered as a brand-new, duplicate message.
  • Fix in the protocol layer (src/lib/irc/handlers/messages.ts): when a PRIVMSG arrives inside a labeled-response batch, propagate the batch's label onto the message's mtags before triggering CHANMSG/USERMSG. Store-side label matching stays untouched.

What was actually on the wire

[A] -> @label=lbl42 PRIVMSG B :Hello, are you there?
[A] <- @label=lbl42 :server BATCH +ID labeled-response
[A] <- @batch=ID :server 301 A B :I am away for tests
[A] <- @batch=ID;msgid=…;time=… :A!u@h PRIVMSG B :Hello, are you there?
[A] <- :server BATCH -ID

The echo line carries @batch=ID but no @label=, so the prior matcher couldn't see it.

Test plan

  • npm run format, npm run fix:unsafe, npm run test (781 pass), npm run build clean
  • New tests/lib/labeledResponseBatch.test.ts covers:
    • inner PRIVMSG inherits label from labeled-response batch opener
    • inner CHANMSG inherits label from labeled-response batch opener
    • plain PRIVMSG (no batch) is left untouched
    • PRIVMSG inside a non-labeled-response batch (e.g. chathistory) does NOT pick up a stray label
    • explicit inner label tag wins over batch label (defensive)
  • Manual: send a PRIVMSG to a user who has /AWAY set; expect a single message in the local view (not two with the optimistic one crossed out)

Scope

Only handlePrivmsg is touched. handleNotice/handleTagmsg don't have label-matching downstream, so propagating there would be inert; left as-is. Nested labeled-response wrapping other batch types is also out of scope (not used in practice).

Summary by CodeRabbit

  • New Features
    • Message status indicators now display pending (spinner appears after 500ms) and failed (retry button) states.
    • Users can retry sending failed messages directly from the chat interface.
    • Enhanced message handling for improved delivery tracking and reliability.

Review Change Stack

When the server emits more than one line in reply to a labeled command
(e.g. PRIVMSG to an /AWAY user, where the server sends both 301 RPL_AWAY
and the echo), the IRCv3 labeled-response spec mandates wrapping them
in a batch -- the label tag lives on the BATCH opener and inner
messages only carry @Batch=ID.

Our pending-message dedup (confirmPendingMessage) looked for
mtags?.label directly on the PRIVMSG echo and never matched in the
batched case. That left the optimistic placeholder to time out into
the "crossed-out" failure state and rendered the server echo as a
new, duplicate message.

Fix: in the protocol layer, when a PRIVMSG arrives inside a
labeled-response batch, propagate the batch's label onto the message's
mtags before dispatching CHANMSG / USERMSG. Store-side label-matching
code stays as-is, so the same code path handles single-line and
batched echoes uniformly. An explicit inner label tag still wins
(defensive; should not happen per spec).
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

Warning

Rate limit exceeded

@ValwareIRC has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 50 minutes and 7 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5cc97a9f-1fcb-49e1-ba77-cc6e9d955363

📥 Commits

Reviewing files that changed from the base of the PR and between 4a62f6c and 87aece7.

📒 Files selected for processing (39)
  • src/components/message/MessageStatusIndicator.tsx
  • src/locales/cs/messages.mjs
  • src/locales/cs/messages.po
  • src/locales/de/messages.mjs
  • src/locales/de/messages.po
  • src/locales/en/messages.mjs
  • src/locales/en/messages.po
  • src/locales/es/messages.mjs
  • src/locales/es/messages.po
  • src/locales/fi/messages.mjs
  • src/locales/fi/messages.po
  • src/locales/fr/messages.mjs
  • src/locales/fr/messages.po
  • src/locales/it/messages.mjs
  • src/locales/it/messages.po
  • src/locales/ja/messages.mjs
  • src/locales/ja/messages.po
  • src/locales/ko/messages.mjs
  • src/locales/ko/messages.po
  • src/locales/nl/messages.mjs
  • src/locales/nl/messages.po
  • src/locales/pl/messages.mjs
  • src/locales/pl/messages.po
  • src/locales/pt/messages.mjs
  • src/locales/pt/messages.po
  • src/locales/ro/messages.mjs
  • src/locales/ro/messages.po
  • src/locales/ru/messages.mjs
  • src/locales/ru/messages.po
  • src/locales/sv/messages.mjs
  • src/locales/sv/messages.po
  • src/locales/tr/messages.mjs
  • src/locales/tr/messages.po
  • src/locales/uk/messages.mjs
  • src/locales/uk/messages.po
  • src/locales/zh-TW/messages.mjs
  • src/locales/zh-TW/messages.po
  • src/locales/zh/messages.mjs
  • src/locales/zh/messages.po
📝 Walkthrough

Walkthrough

The PR adds protocol-level label inheritance from labeled-response BATCH frames to inner messages, then introduces UI components and a store action to display and retry failed messages. Messages sent with new labels from failed retries now integrate with the label-hoisting mechanism for proper deduplication.

Changes

Pending and Failed Message Handling

Layer / File(s) Summary
Batch label inheritance in IRC protocol
src/lib/irc/handlers/messages.ts, tests/protocol/labeledResponseBatch.test.ts
inheritLabelFromBatch() hoists batch labels onto inner PRIVMSG mtags when messages lack their own label. handlePrivmsg emits events with inherited labels. Tests validate hoisting semantics and label precedence.
Message status indicator UI components
src/components/message/MessageStatusIndicator.tsx, src/components/message/icons.tsx, tests/components/MessageStatusIndicator.test.tsx
MessageStatusIndicator renders a delayed spinner (500ms) for pending and a retry button for failed. SpinnerIcon and RefreshIcon SVG icons support the indicator. Tests verify delay behavior and retry callback invocation.
Store action for resending failed messages
src/store/index.ts
resendFailedMessage(messageId) locates the failed message, generates a new label, updates status to pending, sends a labeled PRIVMSG, and schedules a timeout to mark it failed if no response arrives within 30 seconds.
MessageItem styling and status indicator rendering
src/components/message/MessageItem.tsx
Imports MessageStatusIndicator and renders it conditionally when message status is pending or failed, wiring retry clicks to resendFailedMessage(). Pending/failed styling now uses opacity-60 only (removes italic).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • matheusfillipe

Poem

🐰 A batch label hops from frame to frame,
Lost messages find their way back, no shame,
With spinners that spin and buttons to try,
The chat now retries when messages fly! 🔄✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% 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 accurately summarizes the main change: inheriting batch labels onto inner messages in labeled-response BATCHes, which is the core protocol fix addressing pending-message deduplication issues.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-labeled-response-batch-label

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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 and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
tests/lib/labeledResponseBatch.test.ts (1)

1-134: ⚡ Quick win

Move this suite under tests/protocol/ to match test-organization rules.

This suite validates IRC protocol handler behavior, so it should live in the protocol test tree instead of tests/lib/.

As per coding guidelines, tests/**/*.{ts,tsx}: “Mirror src/ structure in tests/ directory … and IRC protocol tests in tests/protocol/”.

🤖 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 `@tests/lib/labeledResponseBatch.test.ts` around lines 1 - 134, The test suite
"labeled-response — batch label inheritance" (file containing tests referencing
handleBatch and handlePrivmsg) is in the wrong test tree; move the entire test
file into the protocol tests directory so it mirrors src/ and lives with other
IRC protocol tests, then update its import paths as needed (e.g., the imports of
"../../src/lib/irc/handlers/messages" and "../../src/lib/irc/IRCClientContext")
so they resolve from the new location and run under the protocol test runner
configuration. Ensure no code changes to tests themselves; just relocate the
file and fix relative imports.
🤖 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.

Nitpick comments:
In `@tests/lib/labeledResponseBatch.test.ts`:
- Around line 1-134: The test suite "labeled-response — batch label inheritance"
(file containing tests referencing handleBatch and handlePrivmsg) is in the
wrong test tree; move the entire test file into the protocol tests directory so
it mirrors src/ and lives with other IRC protocol tests, then update its import
paths as needed (e.g., the imports of "../../src/lib/irc/handlers/messages" and
"../../src/lib/irc/IRCClientContext") so they resolve from the new location and
run under the protocol test runner configuration. Ensure no code changes to
tests themselves; just relocate the file and fix relative imports.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 30fb46c3-0a5d-4d2c-b876-b83569820a33

📥 Commits

Reviewing files that changed from the base of the PR and between ac9cf68 and 2fdba4b.

📒 Files selected for processing (2)
  • src/lib/irc/handlers/messages.ts
  • tests/lib/labeledResponseBatch.test.ts

@github-actions
Copy link
Copy Markdown

Pages Preview
Preview URL: https://fix-labeled-response-batch-l.obsidianirc.pages.dev

Automated deployment preview for the PR in the Cloudflare Pages.

Visual changes to the optimistic-send lifecycle in MessageItem:

- Pending messages no longer flip into italic. Italic→non-italic on
  echo-arrival was a jarring layout shift; the opacity alone is enough
  signal that the message hasn't been confirmed yet.
- After 500ms still pending (i.e. the labeled-response echo hasn't
  arrived), a small spinner appears next to the message. The 500ms
  delay means the common fast-echo case never flashes the spinner.
- Failed messages now show a circular-refresh icon button next to the
  line-through text. Clicking it re-sends the original content with a
  fresh label and re-arms the 30s timeout via a new store action
  `resendFailedMessage(messageId)`.

The spinner SVG is inlined to avoid pulling another react-icons sub-
package; the retry icon is the same Material refresh glyph.
matheusfillipe
matheusfillipe previously approved these changes May 13, 2026
Comment thread src/components/message/MessageStatusIndicator.tsx Outdated
Two reviewer-driven cleanups on top of the labeled-response fix:

- Matt (matheusfillipe) flagged that the inline SVGs in
  MessageStatusIndicator should live in the shared icon module that
  already exists for this exact purpose. Move the spinner and the
  refresh glyph into src/components/message/icons.tsx as
  SpinnerIcon / RefreshIcon, and consume them from
  MessageStatusIndicator.
- CodeRabbit nitpick (and a repo convention point from CLAUDE.md):
  IRC-protocol tests live under tests/protocol/, not tests/lib/.
  Move tests/lib/labeledResponseBatch.test.ts to tests/protocol/.
  No import changes needed since the relative depth is identical.

No behavioural change. Same 785 tests pass; build still clean.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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)
src/store/index.ts (1)

1777-1829: ⚖️ Poor tradeoff

Consider storing the timeout handle to allow cancellation on retry.

When a user retries a failed message multiple times before the 30-second timeout elapses, multiple setTimeout calls accumulate. Each timeout invokes failPendingMessage with its respective label. Since failPendingMessage matches by pendingLabel, only the latest retry's timeout will take effect; earlier timeouts become no-ops. While harmless, this results in unnecessary pending timers.

Optional enhancement: store timeout handle in message state

You could extend the Message type with an optional pendingTimeoutHandle?: NodeJS.Timeout field, then clear it on retry:

 const label = makeLabel();
+let timeoutHandle: NodeJS.Timeout | undefined;
 set((state) => {
   const list = state.messages[channelKey];
   if (!list) return state;
+  const existing = list.find((m) => m.id === messageId);
+  if (existing?.pendingTimeoutHandle) clearTimeout(existing.pendingTimeoutHandle);
   const next = list.map((m) =>
     m.id === messageId
       ? {
           ...m,
           status: "pending" as const,
           pendingLabel: label,
           timestamp: new Date(),
+          pendingTimeoutHandle: undefined,
         }
       : m,
   );
+  timeoutHandle = setTimeout(() => {
+    get().failPendingMessage(msg.serverId, msg.channelId, label);
+  }, 30_000);
+  // Update the message again with the timeout handle
+  const final = next.map((m) =>
+    m.id === messageId ? { ...m, pendingTimeoutHandle: timeoutHandle } : m
+  );
   return {
     messages: {
       ...state.messages,
-      [channelKey]: next,
+      [channelKey]: final,
     },
   };
 });

This avoids accumulating stale timeouts, though the current behavior is functionally correct.

🤖 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 `@src/store/index.ts` around lines 1777 - 1829, resendFailedMessage currently
starts a new setTimeout for failPendingMessage without cancelling any existing
pending timer, causing stale timers to accumulate; update the Message type to
include an optional pendingTimeoutHandle (e.g., pendingTimeoutHandle?:
ReturnType<typeof setTimeout> or NodeJS.Timeout), then in resendFailedMessage
clear any existing timeout for that message before scheduling a new one (use
clearTimeout on the existing pendingTimeoutHandle), store the new timeout handle
in the message state when calling set(...), and ensure failPendingMessage (and
any success path) clears and removes pendingTimeoutHandle so timers are not left
running.
🤖 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 `@src/components/message/MessageStatusIndicator.tsx`:
- Around line 35-48: The JSX contains user-visible strings ("Sending", "Retry
sending") in MessageStatusIndicator that must be internationalized; import the t
macro from '@lingui/macro' at the top of MessageStatusIndicator.tsx and wrap the
aria-label and title prop values (and the "Sending" aria-label on the Spinner
span) with t`...` so they become translatable (e.g., aria-label={t`Sending`} and
aria-label={t`Retry sending`} and title={t`Retry sending`}), leaving onRetry and
existing handlers unchanged.

---

Nitpick comments:
In `@src/store/index.ts`:
- Around line 1777-1829: resendFailedMessage currently starts a new setTimeout
for failPendingMessage without cancelling any existing pending timer, causing
stale timers to accumulate; update the Message type to include an optional
pendingTimeoutHandle (e.g., pendingTimeoutHandle?: ReturnType<typeof setTimeout>
or NodeJS.Timeout), then in resendFailedMessage clear any existing timeout for
that message before scheduling a new one (use clearTimeout on the existing
pendingTimeoutHandle), store the new timeout handle in the message state when
calling set(...), and ensure failPendingMessage (and any success path) clears
and removes pendingTimeoutHandle so timers are not left running.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 009adc4d-efdd-4e87-860e-436f46ae3afc

📥 Commits

Reviewing files that changed from the base of the PR and between 2fdba4b and 4a62f6c.

📒 Files selected for processing (6)
  • src/components/message/MessageItem.tsx
  • src/components/message/MessageStatusIndicator.tsx
  • src/components/message/icons.tsx
  • src/store/index.ts
  • tests/components/MessageStatusIndicator.test.tsx
  • tests/protocol/labeledResponseBatch.test.ts

Comment thread src/components/message/MessageStatusIndicator.tsx Outdated
CodeRabbit pointed out that the new component's aria-label / title
attributes ("Sending", "Retry sending") were user-visible English
strings without i18n wrapping, which violates CLAUDE.md's policy
that aria-label / title in JSX should go through t`...` so
translators can pick them up.

Imported useLingui from @lingui/react/macro (the convention used by
the other components in src/components/message/) and wrapped the
three strings. No runtime behaviour change; the lingui-react test
mock passes English through verbatim so the existing
getByLabelText queries still match. Translations filled in across
all 18 non-English locale catalogs.

Skipped CodeRabbit's other suggestion (storing the setTimeout
handle on the Message to clear it on retry): it's marked "Poor
tradeoff / Nitpick" by CodeRabbit itself ("functionally correct
... unnecessary pending timers"), and threading a non-serializable
NodeJS.Timeout through Zustand state to avoid a small timer object
that fires once and no-ops isn't worth the type-system noise.
@ValwareIRC ValwareIRC merged commit a80540c into main May 13, 2026
5 checks passed
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.

2 participants