Skip to content

Extract composer send-gating logic from Conversations page#1239

Open
jwalin-shah wants to merge 7 commits intotinyhumansai:mainfrom
jwalin-shah:codex/linear-mention-sym-85-extract-chat-composer-behavior-from
Open

Extract composer send-gating logic from Conversations page#1239
jwalin-shah wants to merge 7 commits intotinyhumansai:mainfrom
jwalin-shah:codex/linear-mention-sym-85-extract-chat-composer-behavior-from

Conversation

@jwalin-shah
Copy link
Copy Markdown
Contributor

@jwalin-shah jwalin-shah commented May 5, 2026

Motivation

  • Reduce complexity in Conversations.tsx by extracting the composer send gating and slash-command decision logic into a small, testable seam so behavior can be unit tested and reasoned about independently.
  • Preserve all user-visible chat behavior (welcome-lock, usage-limit modal, socket checks, and send flow) while making the decision surface easier to cover with focused tests.

Description

  • Added a new module app/src/pages/conversations/composerSendDecision.ts that exports evaluateComposerSend(...) for send-gating and handleComposerSlashCommand(...) for /new and /clear handling with welcome-lock awareness.
  • Updated app/src/pages/Conversations.tsx to delegate composer decision logic to the extracted functions and to guard the sendingThreadId usage for type safety.
  • Added unit tests app/src/pages/conversations/composerSendDecision.test.ts that cover empty input, welcome-lock slash command behavior, usage-limit blocking, socket disconnected blocking, and the successful send-path setup.

Testing

  • Ran the focused Vitest suite with pnpm --dir app exec vitest run src/pages/conversations/composerSendDecision.test.ts src/pages/__tests__/Conversations.test.ts --config test/vitest.config.ts and the tests passed (2 test files, 9 tests passed).
  • Ran the TypeScript typecheck with pnpm typecheck and it completed successfully.
  • Attempted the broader pnpm --dir app test -- chat target but it did not finish in this environment, so validation used the focused Vitest commands above which succeeded.

Codex Task

Summary by CodeRabbit

  • New Features

    • Slash-command handling updated to reliably create or clear conversations from the composer
  • Improvements

    • Centralized send-decision flow that trims input and gates sends with clearer outcomes
    • User-facing feedback for usage limits and connection issues
    • Accessibility: Send button gains accessible labels
  • Tests

    • Expanded deterministic tests covering send decisions, slash commands, limits, and thread creation

@jwalin-shah jwalin-shah requested a review from a team May 5, 2026 15:41
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 5, 2026

Warning

Rate limit exceeded

@jwalin-shah has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 37 minutes and 29 seconds before requesting another review.

To continue reviewing without waiting, purchase usage credits 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 78f1622d-d643-467e-9647-ecc5d203ccb6

📥 Commits

Reviewing files that changed from the base of the PR and between 2b3fffb and dc8058c.

📒 Files selected for processing (1)
  • app/src/providers/__tests__/CoreStateProvider.test.tsx
📝 Walkthrough

Walkthrough

This PR centralizes composer send gating by adding a composer-send decision module (evaluateComposerSend, handleComposerSlashCommand, getComposerBlockedSendFeedback) and wiring it into Conversations.tsx; tests and render-suite updates exercise slash commands, usage-limit blocking, and successful send persistence. (50 words)

Changes

Composer Send Decision Pipeline

Layer / File(s) Summary
Data Shape & Types
app/src/pages/conversations/composerSendDecision.ts
Adds ComposerSendBlockReason, SlashCommandDecision, ComposerSendDecisionArgs, ComposerSendDecision, and ComposerBlockedSendFeedback type/interface declarations.
Core Decision Logic
app/src/pages/conversations/composerSendDecision.ts
Implements evaluateComposerSend (trims input, checks empty input, missing thread, composer blocked, usage limit, socket status), handleComposerSlashCommand (handles /new and /clear), and getComposerBlockedSendFeedback (maps block reasons to modal/error feedback).
Component Integration
app/src/pages/Conversations.tsx
Imports new decision functions; introduces handleSlashCommand helper; refactors handleSendMessage to use evaluateComposerSend and getComposerBlockedSendFeedback (shows limit modal or sets send error when blocked); removes prior scattered gating checks; adds aria-label to Send button.
Unit Tests (decision)
app/src/pages/conversations/composerSendDecision.test.ts
Adds tests covering evaluateComposerSend blocking cases and valid send, handleComposerSlashCommand behavior, and getComposerBlockedSendFeedback outputs.
Integration / Render Tests
app/src/pages/__tests__/Conversations.render.test.tsx
Extends render tests with socket selector mock, helpers (selectedThreadState, socketState, renderSelectedConversation, submitComposerText), dynamic threadApi imports; tests /new slash behavior, usage-limit modal, and local message persistence + chat send.

Sequence Diagram

sequenceDiagram
    participant User
    participant Composer as Composer (UI)
    participant Evaluator as evaluateComposerSend
    participant Feedback as getComposerBlockedSendFeedback
    participant Component as Conversations Component
    participant Chat as Chat Service / threadApi

    User->>Composer: Type message & click Send
    Composer->>Component: handleSendMessage(rawText)
    Component->>Evaluator: evaluateComposerSend({rawText, selectedThreadId, composerBlocked, isAtLimit, socketStatus})
    alt shouldSend = true
        Evaluator-->>Component: {shouldSend: true, trimmedText}
        Component->>Component: persist local user message (threadApi.appendMessage)
        Component->>Chat: send via chat service (chatSend)
        Chat-->>Component: ack/response
        Component->>Composer: clear textarea
    else shouldSend = false
        Evaluator-->>Component: {shouldSend: false, blockReason}
        Component->>Feedback: getComposerBlockedSendFeedback(blockReason)
        Feedback-->>Component: {showLimitModal?, error?} or null
        Component->>Composer: show modal or set send error (if provided)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 A little rabbit hops to check the send,
Trims and guards each message to the end.
Slash commands hop, limits get shown,
Threads are made, or waits are known.
Compose, click—validation sings, then send!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.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 'Extract composer send-gating logic from Conversations page' accurately summarizes the main change—refactoring send-gating logic into a separate module to reduce complexity.
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.


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.

…n-sym-85-extract-chat-composer-behavior-from

# Conflicts:
#	app/src/pages/Conversations.tsx
Copy link
Copy Markdown
Member

@senamakel senamakel left a comment

Choose a reason for hiding this comment

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

coverage tests are failing. do write the code to improve test coverage

Copy link
Copy Markdown
Member

@senamakel senamakel left a comment

Choose a reason for hiding this comment

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

coverage tests are failing. do write the code to improve test coverage

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)
app/src/pages/__tests__/Conversations.render.test.tsx (1)

625-628: ⚡ Quick win

Use the existing mocks via static imports instead of await import(...).

These new assertions are already backed by hoisted vi.mock(...) calls, so the extra dynamic imports just add async noise and drift from the repo’s import convention.

♻️ Suggested cleanup
+import { chatSend } from '../../services/chatService';
+import { threadApi } from '../../services/api/threadApi';
@@
   it('handles /new from the composer without sending chat text', async () => {
     const { textarea } = await renderSelectedConversation();
-    const { chatSend } = await import('../../services/chatService');
-    const { threadApi } = await import('../../services/api/threadApi');
@@
   it('shows the usage-limit modal instead of sending when the account is at limit', async () => {
     const { textarea } = await renderSelectedConversation({ isAtLimit: true });
-    const { chatSend } = await import('../../services/chatService');
@@
   it('persists a local user message and sends through chat service for valid input', async () => {
     const { textarea, thread } = await renderSelectedConversation();
-    const { chatSend } = await import('../../services/chatService');
-    const { threadApi } = await import('../../services/api/threadApi');

As per coding guidelines, **/*.{ts,tsx} should “Prefer static imports at the top of the module; do not use dynamic imports (async import(), React.lazy(), await import()).”

Also applies to: 639-655

🤖 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 `@app/src/pages/__tests__/Conversations.render.test.tsx` around lines 625 -
628, Replace the dynamic imports used in the test (e.g., await
import('../../services/chatService') and await
import('../../services/api/threadApi')) with the statically imported mocks
already hoisted for this test file so the assertions use the existing mocked
implementations; locate the test that calls renderSelectedConversation() and
destructures textarea and then references chatSend and threadApi, remove the
await import(...) calls and use the top-level static imports (the mocked
chatSend and threadApi symbols) instead; also apply the same change for the
other occurrences around the 639-655 test block so all tests use the static,
hoisted mocks consistently.
🤖 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 `@app/src/pages/Conversations.tsx`:
- Around line 498-515: The current flow calls evaluateComposerSend and returns
early for sendDecision.blockReason === 'missing_thread' before allowing slash
commands to run; move or replicate the slash handling so
handleSlashCommand(trimmed) is evaluated before the early return for
'missing_thread' (or explicitly allow 'missing_thread' to fall through if
trimmed starts with a slash command). Update the block around
evaluateComposerSend, sendDecision.blockReason checks, and handleSlashCommand
usage so that when selectedThreadId is null the trimmed input is still passed to
handleSlashCommand (referencing evaluateComposerSend, sendDecision.blockReason,
handleSlashCommand, selectedThreadId, and trimmed).

---

Nitpick comments:
In `@app/src/pages/__tests__/Conversations.render.test.tsx`:
- Around line 625-628: Replace the dynamic imports used in the test (e.g., await
import('../../services/chatService') and await
import('../../services/api/threadApi')) with the statically imported mocks
already hoisted for this test file so the assertions use the existing mocked
implementations; locate the test that calls renderSelectedConversation() and
destructures textarea and then references chatSend and threadApi, remove the
await import(...) calls and use the top-level static imports (the mocked
chatSend and threadApi symbols) instead; also apply the same change for the
other occurrences around the 639-655 test block so all tests use the static,
hoisted mocks consistently.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f81f27d5-76d9-44b3-bd26-d6968953506e

📥 Commits

Reviewing files that changed from the base of the PR and between e945390 and fb9c0c7.

📒 Files selected for processing (4)
  • app/src/pages/Conversations.tsx
  • app/src/pages/__tests__/Conversations.render.test.tsx
  • app/src/pages/conversations/composerSendDecision.test.ts
  • app/src/pages/conversations/composerSendDecision.ts

Comment thread app/src/pages/Conversations.tsx Outdated
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 (3)
app/src/pages/Conversations.tsx (2)

487-494: 💤 Low value

welcomeLocked is hardcoded to false; decision.blockedByWelcomeLock is unused.

Consistent with the welcome-lock removal in #1123, but the second argument and the returned blockedByWelcomeLock field are now effectively dead. If welcome-lock isn't coming back, consider simplifying handleComposerSlashCommand's signature in composerSendDecision.ts (drop the parameter and the blockedByWelcomeLock field). Otherwise, leaving the seam in place for future wiring is fine.

🤖 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 `@app/src/pages/Conversations.tsx` around lines 487 - 494, The call in
handleSlashCommand hardcodes welcomeLocked as false
(handleComposerSlashCommand(command, false)) and never uses
decision.blockedByWelcomeLock, leaving a dead parameter/field; either remove the
unused boolean parameter and the blockedByWelcomeLock field from
composerSendDecision.ts and update all call sites (including handleSlashCommand)
to call handleComposerSlashCommand(command) and stop returning
blockedByWelcomeLock, or if you want to keep the seam, thread the real welcome
lock state into handleSlashCommand and pass that state instead of false and
handle decision.blockedByWelcomeLock appropriately; update the function
signature in composerSendDecision.ts and all references
(handleComposerSlashCommand, blockedByWelcomeLock) accordingly.

496-538: 💤 Low value

Send-gating flow looks good — earlier /new ordering concern is addressed.

handleSlashCommand(trimmedInput) now runs before evaluateComposerSend(...), so /new and /clear work even when selectedThreadId is null (i.e. the very state /new is meant to recover from). The if (!sendingThreadId) return; guard at Line 538 is redundant at runtime (the missing_thread early return covers it) but is appropriate for TypeScript narrowing on selectedThreadId: string | null.

One small observation: checkPromptInjection(trimmed) at Line 519 runs and updates sendAdvisory even when the request is about to be blocked by usage-limit/socket reasons at Line 526. That means the advisory text can flash alongside a quota error for messages that never get sent. Probably fine UX-wise (advisory is informational), but worth a sanity check if you want the gate to short-circuit before any prompt-guard side effects.

🤖 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 `@app/src/pages/Conversations.tsx` around lines 496 - 538, Prompt-injection
check (checkPromptInjection) runs and sets sendAdvisory even when sendDecision
later blocks sending (usage limit/socket), causing advisory flash; to fix, defer
calling checkPromptInjection and setSendAdvisory until after you confirm
sendDecision.shouldSend is true (and after handling showLimitModal/sendError),
i.e., move the checkPromptInjection(trimmed) / setSendAdvisory logic to run only
when sendDecision.shouldSend === true (use the existing sendDecision,
getComposerBlockedSendFeedback, and setShowLimitModal/setSendError branches to
gate it), keeping the early type-narrowing guard for selectedThreadId and
preserving handleSlashCommand/evaluateComposerSend ordering.
app/src/pages/__tests__/Conversations.render.test.tsx (1)

625-642: 💤 Low value

/new slash-command test verifies the regression fix — consider also covering /clear.

This test pins the exact bug the previous review flagged: /new works even when selectedThreadId is null (empty thread state with mockGetThreads left pending so no auto-thread is created). Asserting both createNewThread was called and chatSend was not is the right behavior contract.

handleComposerSlashCommand treats /clear identically to /new, but only /new is exercised here. A one-liner mirror test (or a it.each) for /clear would lock that branch in too.

💡 Optional: parametrize over both commands
-  it('handles /new from the composer without a selected thread or sending chat text', async () => {
-    mockGetThreads.mockReturnValue(new Promise(() => {}));
-
-    await act(async () => {
-      await renderConversations({ thread: emptyThreadState, socket: socketState('connected') });
-    });
-    const textarea = await screen.findByPlaceholderText('Type a message...');
-    vi.mocked(threadApi.createNewThread).mockClear();
-    vi.mocked(chatSend).mockClear();
-
-    await submitComposerText(textarea, '/new');
-
-    await waitFor(() => {
-      expect(threadApi.createNewThread).toHaveBeenCalled();
-    });
-    expect(chatSend).not.toHaveBeenCalled();
-    expect(textarea).toHaveValue('');
-  });
+  it.each(['/new', '/clear'])(
+    'handles %s from the composer without a selected thread or sending chat text',
+    async command => {
+      mockGetThreads.mockReturnValue(new Promise(() => {}));
+
+      await act(async () => {
+        await renderConversations({ thread: emptyThreadState, socket: socketState('connected') });
+      });
+      const textarea = await screen.findByPlaceholderText('Type a message...');
+      vi.mocked(threadApi.createNewThread).mockClear();
+      vi.mocked(chatSend).mockClear();
+
+      await submitComposerText(textarea, command);
+
+      await waitFor(() => {
+        expect(threadApi.createNewThread).toHaveBeenCalled();
+      });
+      expect(chatSend).not.toHaveBeenCalled();
+      expect(textarea).toHaveValue('');
+    }
+  );
🤖 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 `@app/src/pages/__tests__/Conversations.render.test.tsx` around lines 625 -
642, Add a mirror test to cover the `/clear` slash-command branch (or
parametrize with it.each over ['/new','/clear']) in
Conversations.render.test.tsx so the same regression is locked: reuse the
existing setup (mockGetThreads pending, renderConversations with thread:
emptyThreadState and socket: socketState('connected')), clear mocks for
vi.mocked(threadApi.createNewThread) and vi.mocked(chatSend), call
submitComposerText(textarea, '/clear') and assert threadApi.createNewThread was
called, chatSend was not called, and the textarea is cleared; reference the
existing helpers submitComposerText, renderConversations,
threadApi.createNewThread, and chatSend to implement the new assertion.
🤖 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 `@app/src/pages/__tests__/Conversations.render.test.tsx`:
- Around line 625-642: Add a mirror test to cover the `/clear` slash-command
branch (or parametrize with it.each over ['/new','/clear']) in
Conversations.render.test.tsx so the same regression is locked: reuse the
existing setup (mockGetThreads pending, renderConversations with thread:
emptyThreadState and socket: socketState('connected')), clear mocks for
vi.mocked(threadApi.createNewThread) and vi.mocked(chatSend), call
submitComposerText(textarea, '/clear') and assert threadApi.createNewThread was
called, chatSend was not called, and the textarea is cleared; reference the
existing helpers submitComposerText, renderConversations,
threadApi.createNewThread, and chatSend to implement the new assertion.

In `@app/src/pages/Conversations.tsx`:
- Around line 487-494: The call in handleSlashCommand hardcodes welcomeLocked as
false (handleComposerSlashCommand(command, false)) and never uses
decision.blockedByWelcomeLock, leaving a dead parameter/field; either remove the
unused boolean parameter and the blockedByWelcomeLock field from
composerSendDecision.ts and update all call sites (including handleSlashCommand)
to call handleComposerSlashCommand(command) and stop returning
blockedByWelcomeLock, or if you want to keep the seam, thread the real welcome
lock state into handleSlashCommand and pass that state instead of false and
handle decision.blockedByWelcomeLock appropriately; update the function
signature in composerSendDecision.ts and all references
(handleComposerSlashCommand, blockedByWelcomeLock) accordingly.
- Around line 496-538: Prompt-injection check (checkPromptInjection) runs and
sets sendAdvisory even when sendDecision later blocks sending (usage
limit/socket), causing advisory flash; to fix, defer calling
checkPromptInjection and setSendAdvisory until after you confirm
sendDecision.shouldSend is true (and after handling showLimitModal/sendError),
i.e., move the checkPromptInjection(trimmed) / setSendAdvisory logic to run only
when sendDecision.shouldSend === true (use the existing sendDecision,
getComposerBlockedSendFeedback, and setShowLimitModal/setSendError branches to
gate it), keeping the early type-narrowing guard for selectedThreadId and
preserving handleSlashCommand/evaluateComposerSend ordering.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a609ceb6-8d06-4e53-8768-d5e96bf3e667

📥 Commits

Reviewing files that changed from the base of the PR and between fb9c0c7 and 2b3fffb.

📒 Files selected for processing (2)
  • app/src/pages/Conversations.tsx
  • app/src/pages/__tests__/Conversations.render.test.tsx

coderabbitai[bot]
coderabbitai Bot previously approved these changes May 6, 2026
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.

3 participants