Skip to content

Commit a08ec95

Browse files
Extract composer send-gating logic from Conversations page (tinyhumansai#1239)
Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
1 parent bbdb823 commit a08ec95

4 files changed

Lines changed: 385 additions & 34 deletions

File tree

app/src/pages/Conversations.tsx

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ import { AgentMessageBubble, BubbleMarkdown } from './conversations/components/A
5858
import { CitationChips, type MessageCitation } from './conversations/components/CitationChips';
5959
import { LimitPill } from './conversations/components/LimitPill';
6060
import { ToolTimelineBlock } from './conversations/components/ToolTimelineBlock';
61+
import {
62+
evaluateComposerSend,
63+
getComposerBlockedSendFeedback,
64+
handleComposerSlashCommand,
65+
} from './conversations/composerSendDecision';
6166
import {
6267
type AgentBubblePosition,
6368
buildAcceptedInlineCompletion,
@@ -489,30 +494,36 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
489494
}, [inputMode, rustChat]);
490495

491496
const handleSlashCommand = (command: string): boolean => {
492-
const cmd = command.toLowerCase();
493-
if (cmd === '/new' || cmd === '/clear') {
494-
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
495-
// Welcome lockdown (#883) — consume the command so it is not sent
496-
// to the agent, but skip thread creation/reset so the user cannot
497-
// escape the welcome conversation via `/new` or `/clear`.
498-
// if (welcomeLocked) {
499-
// setInputValue('');
500-
// return true;
501-
// }
502-
setInputValue('');
503-
void handleCreateNewThread();
504-
return true;
505-
}
506-
return false;
497+
const decision = handleComposerSlashCommand(command, false);
498+
if (decision.kind === 'not_handled') return false;
499+
500+
setInputValue('');
501+
void handleCreateNewThread();
502+
return true;
507503
};
508504

509505
const handleSendMessage = async (text?: string) => {
510506
const normalized = text ?? inputValue;
511-
const trimmed = normalized.trim();
507+
const trimmedInput = normalized.trim();
512508

513-
if (!trimmed || !selectedThreadId || composerInteractionBlocked) return;
509+
if (handleSlashCommand(trimmedInput)) return;
514510

515-
if (handleSlashCommand(trimmed)) return;
511+
const sendDecision = evaluateComposerSend({
512+
rawText: normalized,
513+
selectedThreadId,
514+
composerInteractionBlocked,
515+
isAtLimit,
516+
socketStatus,
517+
});
518+
const trimmed = sendDecision.trimmedText;
519+
520+
if (
521+
sendDecision.blockReason === 'empty_input' ||
522+
sendDecision.blockReason === 'missing_thread' ||
523+
sendDecision.blockReason === 'composer_blocked'
524+
) {
525+
return;
526+
}
516527

517528
const promptGuard = checkPromptInjection(trimmed);
518529
if (promptGuard.verdict === 'review' || promptGuard.verdict === 'block') {
@@ -521,24 +532,19 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
521532
setSendAdvisory(null);
522533
}
523534

524-
if (isAtLimit) {
525-
setShowLimitModal(true);
526-
setSendError(
527-
chatSendError('usage_limit_reached', 'Usage limit reached. Upgrade or wait for reset.')
528-
);
529-
return;
530-
}
531-
if (socketStatus !== 'connected') {
532-
setSendError(
533-
chatSendError(
534-
'socket_disconnected',
535-
'Realtime socket is not connected — responses cannot be delivered without a client ID.'
536-
)
537-
);
535+
if (!sendDecision.shouldSend) {
536+
const blockedFeedback = getComposerBlockedSendFeedback(sendDecision.blockReason);
537+
if (blockedFeedback?.showLimitModal) {
538+
setShowLimitModal(true);
539+
}
540+
if (blockedFeedback) {
541+
setSendError(chatSendError(blockedFeedback.error.code, blockedFeedback.error.message));
542+
}
538543
return;
539544
}
540545

541546
const sendingThreadId = selectedThreadId;
547+
if (!sendingThreadId) return;
542548
const userMessage: ThreadMessage = {
543549
id: `msg_${globalThis.crypto.randomUUID()}`,
544550
content: trimmed,
@@ -1624,6 +1630,8 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
16241630
{/* Voice input mic hidden per #717 (inputMode='voice' path retained). */}
16251631
</div>
16261632
<button
1633+
aria-label="Send message"
1634+
title="Send message"
16271635
onClick={() => {
16281636
void handleSendMessage();
16291637
}}

app/src/pages/__tests__/Conversations.render.test.tsx

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { Provider } from 'react-redux';
1313
import { MemoryRouter } from 'react-router-dom';
1414
import { beforeEach, describe, expect, it, vi } from 'vitest';
1515

16+
import { threadApi } from '../../services/api/threadApi';
17+
import { chatSend } from '../../services/chatService';
1618
import chatRuntimeReducer from '../../store/chatRuntimeSlice';
1719
import socketReducer from '../../store/socketSlice';
1820
import threadReducer from '../../store/threadSlice';
@@ -74,6 +76,11 @@ vi.mock('../../services/api/threadApi', () => ({
7476

7577
vi.mock('../../hooks/useUsageState', () => ({ useUsageState: mockUseUsageState }));
7678

79+
vi.mock('../../store/socketSelectors', () => ({
80+
selectSocketStatus: (state: { socket?: { byUser?: Record<string, { status: string }> } }) =>
81+
state.socket?.byUser?.__pending__?.status ?? 'disconnected',
82+
}));
83+
7784
// useStickToBottom returns refs; mock it so layout-effects don't fire in jsdom.
7885
vi.mock('../../hooks/useStickToBottom', () => ({
7986
useStickToBottom: vi.fn(() => ({ containerRef: { current: null }, endRef: { current: null } })),
@@ -162,6 +169,69 @@ const emptyThreadState = {
162169
messagesError: null,
163170
};
164171

172+
function selectedThreadState(thread: Thread) {
173+
return {
174+
...emptyThreadState,
175+
threads: [thread],
176+
selectedThreadId: thread.id,
177+
messagesByThreadId: { [thread.id]: [] },
178+
messages: [],
179+
};
180+
}
181+
182+
function socketState(status: 'connected' | 'disconnected') {
183+
return {
184+
byUser: { __pending__: { status, socketId: status === 'connected' ? 'socket-1' : null } },
185+
};
186+
}
187+
188+
async function renderSelectedConversation(
189+
options: { isAtLimit?: boolean; socketStatus?: 'connected' | 'disconnected' } = {}
190+
) {
191+
const thread = makeThread({ id: 'send-thread', title: 'Send Thread' });
192+
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
193+
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
194+
mockUseUsageState.mockReturnValue({
195+
teamUsage: null,
196+
currentPlan: null,
197+
currentTier: 'FREE' as const,
198+
isFreeTier: true,
199+
usagePct10h: options.isAtLimit ? 1 : 0,
200+
usagePct7d: options.isAtLimit ? 1 : 0,
201+
isNearLimit: Boolean(options.isAtLimit),
202+
isAtLimit: Boolean(options.isAtLimit),
203+
isRateLimited: Boolean(options.isAtLimit),
204+
isBudgetExhausted: false,
205+
shouldShowBudgetCompletedMessage: false,
206+
isLoading: false,
207+
refresh: vi.fn(),
208+
});
209+
210+
let renderedStore: ReturnType<typeof buildStore> | undefined;
211+
await act(async () => {
212+
renderedStore = await renderConversations({
213+
thread: selectedThreadState(thread),
214+
socket: socketState(options.socketStatus ?? 'connected'),
215+
});
216+
});
217+
218+
const textarea = await screen.findByPlaceholderText('Type a message...');
219+
return { store: renderedStore, textarea, thread };
220+
}
221+
222+
async function submitComposerText(textarea: HTMLElement, text: string) {
223+
await act(async () => {
224+
fireEvent.change(textarea, { target: { value: text } });
225+
});
226+
await waitFor(() => {
227+
expect(textarea).toHaveValue(text);
228+
expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
229+
});
230+
await act(async () => {
231+
fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
232+
});
233+
}
234+
165235
// ── Tests ──────────────────────────────────────────────────────────────────
166236

167237
describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
@@ -348,7 +418,6 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
348418
});
349419

350420
// createNewThread was called — verifies line 919 callback executed
351-
const { threadApi } = await import('../../services/api/threadApi');
352421
expect(threadApi.createNewThread).toHaveBeenCalled();
353422
});
354423

@@ -372,7 +441,6 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
372441
});
373442

374443
// createNewThread was called — verifies line 1061 callback executed
375-
const { threadApi } = await import('../../services/api/threadApi');
376444
expect(threadApi.createNewThread).toHaveBeenCalled();
377445
});
378446

@@ -553,4 +621,53 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
553621
// isRateLimited=true, shouldShowBudgetCompletedMessage=false → rate-limit branch (line 1437)
554622
expect(screen.getByText(/10-hour rate limit reached/i)).toBeInTheDocument();
555623
});
624+
625+
it('handles /new from the composer without a selected thread or sending chat text', async () => {
626+
mockGetThreads.mockReturnValue(new Promise(() => {}));
627+
628+
await act(async () => {
629+
await renderConversations({ thread: emptyThreadState, socket: socketState('connected') });
630+
});
631+
const textarea = await screen.findByPlaceholderText('Type a message...');
632+
vi.mocked(threadApi.createNewThread).mockClear();
633+
vi.mocked(chatSend).mockClear();
634+
635+
await submitComposerText(textarea, '/new');
636+
637+
await waitFor(() => {
638+
expect(threadApi.createNewThread).toHaveBeenCalled();
639+
});
640+
expect(chatSend).not.toHaveBeenCalled();
641+
expect(textarea).toHaveValue('');
642+
});
643+
644+
it('shows the usage-limit modal instead of sending when the account is at limit', async () => {
645+
const { textarea } = await renderSelectedConversation({ isAtLimit: true });
646+
647+
await submitComposerText(textarea, 'hello at limit');
648+
649+
await waitFor(() => {
650+
expect(screen.getByText('Usage Limit Reached')).toBeInTheDocument();
651+
});
652+
expect(screen.getByText(/Usage limit reached/i)).toBeInTheDocument();
653+
expect(chatSend).not.toHaveBeenCalled();
654+
});
655+
656+
it('persists a local user message and sends through chat service for valid input', async () => {
657+
const { textarea, thread } = await renderSelectedConversation();
658+
659+
await submitComposerText(textarea, ' hello cloud ');
660+
661+
await waitFor(() => {
662+
expect(threadApi.appendMessage).toHaveBeenCalledWith(
663+
thread.id,
664+
expect.objectContaining({ content: 'hello cloud', sender: 'user', type: 'text' })
665+
);
666+
});
667+
expect(chatSend).toHaveBeenCalledWith({
668+
threadId: thread.id,
669+
message: 'hello cloud',
670+
model: 'reasoning-v1',
671+
});
672+
});
556673
});

0 commit comments

Comments
 (0)