Skip to content

feat(whatsapp): 24h session countdown + agent compose box#869

Merged
korutx merged 1 commit into
trunkfrom
based/wa-session-compose
Jul 1, 2026
Merged

feat(whatsapp): 24h session countdown + agent compose box#869
korutx merged 1 commit into
trunkfrom
based/wa-session-compose

Conversation

@korutx

@korutx korutx commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Why

The service-thread inbox (#867) is read-only — an operator can see a driver's reply but can't answer.
This makes it two-way. Under WhatsApp's rule, free-form replies are only valid within 24h of the
driver's last inbound (the modulith already tracks this as sessionExpiresAt), so the UI has to make
that window visible and gate free-text on it.

What

  • SessionCountdown — a header pill next to the service badge: green while the window is comfortably
    open (live Hh Mm countdown), amber in the last 2h, grey once closed.
  • ComposeBox — an agent reply box at the foot of the thread, enabled only while the window is open;
    when closed it collapses to a hint that an approved template is needed to reopen. The reply carries
    the thread's service code so it lands on the same service thread; a rejected send (e.g. the dev
    test-mode allow-list) surfaces the modulith's reason inline. On success it refreshes the thread +
    conversation list.
  • session-window.ts — pure window-state logic (open / closingSoon / closed + remaining), unit-tested.
  • use-session-window.ts — ticks every 30s and is mounted-guarded so the time value doesn't cause a
    hydration mismatch.
  • Reuses the existing POST …/whatsapp/messages proxy; adds sendTextMessage. i18n (en/es).

Demo beat it completes

System rejects a POD → driver replies on the service thread → agent sees the live window and replies
back
, all in one pane.

Scoped out (follow-ups)

  • Template picker when the window is closed (hint only for now) — to reopen a lapsed conversation.
  • Server-side enforcement of the window (the FE gate + Meta's own rejection cover it today); natural to
    add alongside the template path.

Verification

apps/app check-types green; 8 session-window unit tests pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GAzRnBWEgiTESmwzRNLMzu

Summary by CodeRabbit

  • New Features

    • Added a WhatsApp reply composer with send support, inline error handling, and keyboard shortcut submission.
    • Added a visible reply-window status indicator with live countdown and clearer messaging for open, closing soon, and closed states.
    • Added support for refreshing the conversation after a message is sent.
  • Bug Fixes

    • Improved sending rules so replies are only allowed when the conversation window is open and the message is not empty.
    • Added clearer error messages when sending fails.

Closes #870

Make the inbox two-way. Under WhatsApp's rule, free-form replies are only valid
within 24h of the driver's last inbound (the modulith already tracks this as
sessionExpiresAt), so:

- SessionCountdown: a header pill — green while open (live Hh Mm countdown),
  amber in the last 2h, grey once closed. session-window.ts holds the pure
  state logic (unit-tested); use-session-window.ts ticks it every 30s and is
  mounted-guarded to avoid a hydration mismatch on the time value.
- ComposeBox: an agent reply box at the foot of the thread, enabled only while
  the window is open; when closed it collapses to a hint that a template is
  needed to reopen. The reply carries the thread's service code so it lands on
  the same service thread; a rejected send (e.g. test-mode allow-list) shows the
  modulith's reason inline. On success it refreshes the thread + conversation list.
- Reuses the existing POST …/whatsapp/messages proxy; adds sendTextMessage.
- i18n (en/es) for the compose + window states.

check-types green; 8 session-window unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAzRnBWEgiTESmwzRNLMzu
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a WhatsApp reply-window feature: pure session-window state helpers with tests, a client hook to poll session expiry, a countdown UI pill, a ComposeBox for sending replies gated by window status, a new sendTextMessage data-service call, MessageThread wiring, and English/Spanish translation strings.

Changes

Reply window and compose feature

Layer / File(s) Summary
Session window state model
.../session-window.ts, .../session-window.test.ts
Adds WindowStatus, WindowState, windowState(), and formatRemaining() with unit tests covering closed/open/closingSoon transitions and duration formatting.
useSessionWindow hook
.../use-session-window.ts
Client hook polling nowMs every 30s and deriving WindowState from windowState(), returning null before mount.
SessionCountdown component
.../components/session-countdown.tsx
Renders a pill UI mapping WindowStatus to styles/i18n labels, showing a live countdown when not closed.
sendTextMessage data service
.../inbox-data-service.ts
Adds SendTextInput interface and sendTextMessage() posting to the WhatsApp messages endpoint with error handling.
ComposeBox component
.../components/compose-box.tsx
New reply input component gated by windowOpen, sending via sendTextMessage, with inline error display and Enter-to-send behavior.
MessageThread integration
.../components/message-thread.tsx
Wires useSessionWindow, SessionCountdown, and ComposeBox into the thread, refreshing messages on successful send.
Translation strings
.../lang/en.json, .../lang/es.json
Adds English/Spanish strings for compose placeholder, send action/error, and window status/hint text.

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

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant ComposeBox
    participant InboxDataService as inbox-data-service
    participant API as WhatsApp Messages API
    participant MessageThread

    User->>ComposeBox: Type reply and press Enter/Send
    ComposeBox->>ComposeBox: Check canSend (windowOpen, text, sending)
    ComposeBox->>InboxDataService: sendTextMessage(to, body, serviceCode, driverId)
    InboxDataService->>API: POST /app/api/whatsapp/messages
    API-->>InboxDataService: Message or error
    alt success
        InboxDataService-->>ComposeBox: Message
        ComposeBox->>ComposeBox: Clear text
        ComposeBox->>MessageThread: onSent()
        MessageThread->>MessageThread: refreshMessages(), onRead()
    else error
        InboxDataService-->>ComposeBox: throw error
        ComposeBox->>User: Display inline error
    end
Loading

Possibly related PRs

  • microboxlabs/modulariot#783: Adds the corresponding POST /app/api/whatsapp/messages route handler consumed by this PR's sendTextMessage.

Suggested labels: enhancement

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% 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
Title check ✅ Passed The title accurately captures the main WhatsApp changes: the 24h session countdown and the agent compose box.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch based/wa-session-compose

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.

@sonarqubecloud

sonarqubecloud Bot commented Jul 1, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

App preview image

The latest app preview image for this PR is ready.

  • Immutable image: ghcr.io/microboxlabs/miot-app@sha256:57aee31ec476578a8fb9396c5d89c704013bf31a9becc7e87205ba9711a4a400
  • Moving tag: ghcr.io/microboxlabs/miot-app:pr-869
  • SHA tag: ghcr.io/microboxlabs/miot-app:pr-869-sha-705485a

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (5)
turbo-repo/apps/app/src/features/whatsapp-inbox/components/session-countdown.tsx (1)

12-16: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

LABEL_KEY.closed is unused dead branch.

LABEL_KEY["closed"] = "windowClosed" is never reached because the ternary at Line 31 special-cases status === "closed" before the LABEL_KEY lookup happens.

♻️ Simplify by dropping the special case
-  const label =
-    state.status === "closed"
-      ? tr("windowClosed", dict)
-      : `${tr(LABEL_KEY[state.status], dict)} · ${formatRemaining(state.remainingMs)}`;
+  const suffix = formatRemaining(state.remainingMs);
+  const label = suffix
+    ? `${tr(LABEL_KEY[state.status], dict)} · ${suffix}`
+    : tr(LABEL_KEY[state.status], dict);

Also applies to: 30-33

🤖 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
`@turbo-repo/apps/app/src/features/whatsapp-inbox/components/session-countdown.tsx`
around lines 12 - 16, The LABEL_KEY.closed entry is dead because
session-countdown.tsx special-cases status === "closed" before the lookup, so
the closed branch is never used. Update the countdown label selection in the
session-countdown component to remove that special case and rely on LABEL_KEY
for all WindowStatus values, keeping only the existing open and closingSoon
mappings plus the closed mapping if needed by the status lookup.
turbo-repo/apps/app/src/features/whatsapp-inbox/session-window.test.ts (1)

26-28: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider adding a boundary test at exactly the 2h threshold.

Current tests exercise 1h (closingSoon) and 5h (open) but not the remainingMs === CLOSING_SOON_MS edge (<= in the implementation), which is the actual transition point.

✅ Suggested boundary test
+  it("is closingSoon at exactly the 2h threshold", () => {
+    expect(windowState(hours(2), NOW).status).toBe("closingSoon");
+  });
🤖 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 `@turbo-repo/apps/app/src/features/whatsapp-inbox/session-window.test.ts`
around lines 26 - 28, The `windowState` tests are missing the exact
`CLOSING_SOON_MS` boundary, so add a case that asserts the status at precisely
2h remaining to cover the `<=` transition. Keep the existing `is closingSoon
within the last 2h` and `is open after 2h` cases, and add a focused boundary
assertion in the `windowState` test block using `windowState` and `hours(...)`
so the edge behavior is verified directly.
turbo-repo/apps/app/src/features/whatsapp-inbox/inbox-data-service.ts (1)

58-67: 📐 Maintainability & Code Quality | 🔵 Trivial

Use an explicit error type instead of generic Error.

As per coding guidelines, turbo-repo/apps/app/**/*.{ts,tsx} should "Use explicit error types, not generic Error" and "Provide meaningful error messages with context." A dedicated WhatsAppSendError (carrying the HTTP status and backend reason) would let callers distinguish rejection causes from network failures.

♻️ Proposed fix
+export class WhatsAppSendError extends Error {
+  constructor(message: string, readonly status: number) {
+    super(message);
+    this.name = "WhatsAppSendError";
+  }
+}
+
 export async function sendTextMessage(input: SendTextInput): Promise<Message> {
   const res = await fetch("/app/api/whatsapp/messages", {
     method: "POST",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify({
       to: input.to,
       type: "TEXT",
       body: input.body,
       serviceCode: input.serviceCode,
       driverId: input.driverId,
     }),
   });
   if (!res.ok) {
     let message = `Send failed (${res.status})`;
     try {
       const data = (await res.json()) as { error?: string };
       if (data?.error) message = data.error;
     } catch {
       /* non-JSON error body — keep the status-based message */
     }
-    throw new Error(message);
+    throw new WhatsAppSendError(message, res.status);
   }
   return (await res.json()) as Message;
 }
🤖 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 `@turbo-repo/apps/app/src/features/whatsapp-inbox/inbox-data-service.ts` around
lines 58 - 67, The failure path in the WhatsApp send flow currently throws a
generic Error from the res.ok check, which violates the explicit error-type
guideline. Replace that throw in inbox-data-service.ts with a dedicated
WhatsAppSendError that carries the HTTP status and backend reason, and use it in
the send logic so callers can distinguish API rejections from network failures.
Keep the existing message extraction from res.json, but store the status and
reason on the custom error and update the surrounding send/response handling to
reference WhatsAppSendError by name.

Source: Coding guidelines

turbo-repo/apps/app/src/features/whatsapp-inbox/components/compose-box.tsx (1)

1-92: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

No test coverage for the send flow.

sendTextMessage and ComposeBox are new critical-path code (agent replies to customers) but ship without tests, unlike session-window.ts. As per coding guidelines, turbo-repo/apps/app/**/*.test.{ts,tsx} expects "Add or update tests when behavior changes, even if not explicitly requested."
Want me to draft @testing-library/react tests for ComposeBox (send success/error, disabled states) and a mocked-fetch test for sendTextMessage?

🤖 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 `@turbo-repo/apps/app/src/features/whatsapp-inbox/components/compose-box.tsx`
around lines 1 - 92, Add tests for the new send flow in ComposeBox and
sendTextMessage, since the behavior changed without coverage. Create
`@testing-library/react` tests for ComposeBox that cover successful send, error
display, and disabled states based on windowOpen/sending/empty input, and add a
mocked-fetch test for sendTextMessage to verify request handling and failure
paths. Use the ComposeBox component and sendTextMessage helper as the main
anchors for locating the code.

Source: Coding guidelines

turbo-repo/apps/app/src/features/whatsapp-inbox/components/message-thread.tsx (1)

91-91: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplify with optional chaining.

As per coding guidelines, turbo-repo/apps/app/**/*.{ts,tsx} should "Use null-safe operations (?. and ?? operators) over multiple null checks."

♻️ Proposed fix
-        windowOpen={windowInfo === null ? true : windowInfo.status !== "closed"}
+        windowOpen={windowInfo?.status !== "closed"}
🤖 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
`@turbo-repo/apps/app/src/features/whatsapp-inbox/components/message-thread.tsx`
at line 91, The windowOpen prop in the MessageThread component uses an explicit
null check and status comparison; replace the nested null logic with a null-safe
optional chaining expression using windowInfo?.status and a fallback so it
follows the project’s null-safe operator guideline. Keep the behavior equivalent
while simplifying the condition around windowInfo.

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 `@turbo-repo/apps/app/src/features/whatsapp-inbox/components/compose-box.tsx`:
- Around line 33-53: Prevent duplicate WhatsApp sends in handleSend by adding a
synchronous in-flight guard with a ref, since canSend and sending are
render-time state and Enter auto-repeat can re-enter before disable commits.
Update compose-box.tsx by using a useRef-based lock inside handleSend (and clear
it in finally) alongside the existing sending state, and add useRef to the react
import so the guard is checked before calling sendTextMessage.

---

Nitpick comments:
In `@turbo-repo/apps/app/src/features/whatsapp-inbox/components/compose-box.tsx`:
- Around line 1-92: Add tests for the new send flow in ComposeBox and
sendTextMessage, since the behavior changed without coverage. Create
`@testing-library/react` tests for ComposeBox that cover successful send, error
display, and disabled states based on windowOpen/sending/empty input, and add a
mocked-fetch test for sendTextMessage to verify request handling and failure
paths. Use the ComposeBox component and sendTextMessage helper as the main
anchors for locating the code.

In
`@turbo-repo/apps/app/src/features/whatsapp-inbox/components/message-thread.tsx`:
- Line 91: The windowOpen prop in the MessageThread component uses an explicit
null check and status comparison; replace the nested null logic with a null-safe
optional chaining expression using windowInfo?.status and a fallback so it
follows the project’s null-safe operator guideline. Keep the behavior equivalent
while simplifying the condition around windowInfo.

In
`@turbo-repo/apps/app/src/features/whatsapp-inbox/components/session-countdown.tsx`:
- Around line 12-16: The LABEL_KEY.closed entry is dead because
session-countdown.tsx special-cases status === "closed" before the lookup, so
the closed branch is never used. Update the countdown label selection in the
session-countdown component to remove that special case and rely on LABEL_KEY
for all WindowStatus values, keeping only the existing open and closingSoon
mappings plus the closed mapping if needed by the status lookup.

In `@turbo-repo/apps/app/src/features/whatsapp-inbox/inbox-data-service.ts`:
- Around line 58-67: The failure path in the WhatsApp send flow currently throws
a generic Error from the res.ok check, which violates the explicit error-type
guideline. Replace that throw in inbox-data-service.ts with a dedicated
WhatsAppSendError that carries the HTTP status and backend reason, and use it in
the send logic so callers can distinguish API rejections from network failures.
Keep the existing message extraction from res.json, but store the status and
reason on the custom error and update the surrounding send/response handling to
reference WhatsAppSendError by name.

In `@turbo-repo/apps/app/src/features/whatsapp-inbox/session-window.test.ts`:
- Around line 26-28: The `windowState` tests are missing the exact
`CLOSING_SOON_MS` boundary, so add a case that asserts the status at precisely
2h remaining to cover the `<=` transition. Keep the existing `is closingSoon
within the last 2h` and `is open after 2h` cases, and add a focused boundary
assertion in the `windowState` test block using `windowState` and `hours(...)`
so the edge behavior is verified directly.
🪄 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: 8476e4bd-bf24-4ab5-a322-fe51b5b62a8e

📥 Commits

Reviewing files that changed from the base of the PR and between 6fdc85d and 705485a.

📒 Files selected for processing (9)
  • turbo-repo/apps/app/src/features/whatsapp-inbox/components/compose-box.tsx
  • turbo-repo/apps/app/src/features/whatsapp-inbox/components/message-thread.tsx
  • turbo-repo/apps/app/src/features/whatsapp-inbox/components/session-countdown.tsx
  • turbo-repo/apps/app/src/features/whatsapp-inbox/inbox-data-service.ts
  • turbo-repo/apps/app/src/features/whatsapp-inbox/session-window.test.ts
  • turbo-repo/apps/app/src/features/whatsapp-inbox/session-window.ts
  • turbo-repo/apps/app/src/features/whatsapp-inbox/use-session-window.ts
  • turbo-repo/apps/app/src/lang/en.json
  • turbo-repo/apps/app/src/lang/es.json

Comment on lines +33 to +53
async function handleSend() {
if (!canSend) {
return;
}
setSending(true);
setError(null);
try {
await sendTextMessage({
to: conversation.phoneE164,
body: text.trim(),
serviceCode: conversation.contextServiceCode,
driverId: conversation.driverId,
});
setText("");
onSent();
} catch (e) {
setError(e instanceof Error ? e.message : tr("sendError", dict));
} finally {
setSending(false);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Guard against duplicate sends from Enter-key auto-repeat.

canSend is a render-time snapshot; rapid/held Enter presses can fire handleSend again before the sending state update commits, bypassing the button's disabled guard and issuing a duplicate WhatsApp send. A synchronous ref check closes this gap.

🔒 Proposed fix
+  const sendingRef = useRef(false);
+
   async function handleSend() {
-    if (!canSend) {
+    if (!canSend || sendingRef.current) {
       return;
     }
+    sendingRef.current = true;
     setSending(true);
     setError(null);
     try {
       await sendTextMessage({
         to: conversation.phoneE164,
         body: text.trim(),
         serviceCode: conversation.contextServiceCode,
         driverId: conversation.driverId,
       });
       setText("");
       onSent();
     } catch (e) {
       setError(e instanceof Error ? e.message : tr("sendError", dict));
     } finally {
       setSending(false);
+      sendingRef.current = false;
     }
   }

(Add useRef to the react import.)

Also applies to: 70-75

🤖 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 `@turbo-repo/apps/app/src/features/whatsapp-inbox/components/compose-box.tsx`
around lines 33 - 53, Prevent duplicate WhatsApp sends in handleSend by adding a
synchronous in-flight guard with a ref, since canSend and sending are
render-time state and Enter auto-repeat can re-enter before disable commits.
Update compose-box.tsx by using a useRef-based lock inside handleSend (and clear
it in finally) alongside the existing sending state, and add useRef to the react
import so the guard is checked before calling sendTextMessage.

@korutx korutx merged commit bb37f23 into trunk Jul 1, 2026
14 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.

feat(whatsapp): 24h session countdown and agent compose box

1 participant