feat(whatsapp): 24h session countdown + agent compose box#869
Conversation
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
📝 WalkthroughWalkthroughAdds 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. ChangesReply window and compose feature
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
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
|
App preview imageThe latest app preview image for this PR is ready.
|
There was a problem hiding this comment.
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.closedis unused dead branch.
LABEL_KEY["closed"] = "windowClosed"is never reached because the ternary at Line 31 special-casesstatus === "closed"before theLABEL_KEYlookup 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 valueConsider adding a boundary test at exactly the 2h threshold.
Current tests exercise 1h (closingSoon) and 5h (open) but not the
remainingMs === CLOSING_SOON_MSedge (<=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 | 🔵 TrivialUse 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 dedicatedWhatsAppSendError(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 liftNo test coverage for the send flow.
sendTextMessageandComposeBoxare new critical-path code (agent replies to customers) but ship without tests, unlikesession-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/reacttests forComposeBox(send success/error, disabled states) and a mocked-fetch test forsendTextMessage?🤖 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 valueSimplify 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
📒 Files selected for processing (9)
turbo-repo/apps/app/src/features/whatsapp-inbox/components/compose-box.tsxturbo-repo/apps/app/src/features/whatsapp-inbox/components/message-thread.tsxturbo-repo/apps/app/src/features/whatsapp-inbox/components/session-countdown.tsxturbo-repo/apps/app/src/features/whatsapp-inbox/inbox-data-service.tsturbo-repo/apps/app/src/features/whatsapp-inbox/session-window.test.tsturbo-repo/apps/app/src/features/whatsapp-inbox/session-window.tsturbo-repo/apps/app/src/features/whatsapp-inbox/use-session-window.tsturbo-repo/apps/app/src/lang/en.jsonturbo-repo/apps/app/src/lang/es.json
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
🗄️ 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.



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 makethat window visible and gate free-text on it.
What
open (live
Hh Mmcountdown), amber in the last 2h, grey once closed.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.
hydration mismatch.
POST …/whatsapp/messagesproxy; addssendTextMessage. 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)
add alongside the template path.
Verification
apps/appcheck-typesgreen; 8session-windowunit tests pass.🤖 Generated with Claude Code
https://claude.ai/code/session_01GAzRnBWEgiTESmwzRNLMzu
Summary by CodeRabbit
New Features
Bug Fixes
Closes #870