Extract composer send-gating logic from Conversations page (#1239)

Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
This commit is contained in:
Jwalin Shah
2026-05-06 16:47:38 -07:00
committed by GitHub
co-authored by Jwalin Shah
parent bbdb823eda
commit a08ec95338
4 changed files with 385 additions and 34 deletions
+40 -32
View File
@@ -58,6 +58,11 @@ import { AgentMessageBubble, BubbleMarkdown } from './conversations/components/A
import { CitationChips, type MessageCitation } from './conversations/components/CitationChips';
import { LimitPill } from './conversations/components/LimitPill';
import { ToolTimelineBlock } from './conversations/components/ToolTimelineBlock';
import {
evaluateComposerSend,
getComposerBlockedSendFeedback,
handleComposerSlashCommand,
} from './conversations/composerSendDecision';
import {
type AgentBubblePosition,
buildAcceptedInlineCompletion,
@@ -489,30 +494,36 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
}, [inputMode, rustChat]);
const handleSlashCommand = (command: string): boolean => {
const cmd = command.toLowerCase();
if (cmd === '/new' || cmd === '/clear') {
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
// Welcome lockdown (#883) — consume the command so it is not sent
// to the agent, but skip thread creation/reset so the user cannot
// escape the welcome conversation via `/new` or `/clear`.
// if (welcomeLocked) {
// setInputValue('');
// return true;
// }
setInputValue('');
void handleCreateNewThread();
return true;
}
return false;
const decision = handleComposerSlashCommand(command, false);
if (decision.kind === 'not_handled') return false;
setInputValue('');
void handleCreateNewThread();
return true;
};
const handleSendMessage = async (text?: string) => {
const normalized = text ?? inputValue;
const trimmed = normalized.trim();
const trimmedInput = normalized.trim();
if (!trimmed || !selectedThreadId || composerInteractionBlocked) return;
if (handleSlashCommand(trimmedInput)) return;
if (handleSlashCommand(trimmed)) return;
const sendDecision = evaluateComposerSend({
rawText: normalized,
selectedThreadId,
composerInteractionBlocked,
isAtLimit,
socketStatus,
});
const trimmed = sendDecision.trimmedText;
if (
sendDecision.blockReason === 'empty_input' ||
sendDecision.blockReason === 'missing_thread' ||
sendDecision.blockReason === 'composer_blocked'
) {
return;
}
const promptGuard = checkPromptInjection(trimmed);
if (promptGuard.verdict === 'review' || promptGuard.verdict === 'block') {
@@ -521,24 +532,19 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
setSendAdvisory(null);
}
if (isAtLimit) {
setShowLimitModal(true);
setSendError(
chatSendError('usage_limit_reached', 'Usage limit reached. Upgrade or wait for reset.')
);
return;
}
if (socketStatus !== 'connected') {
setSendError(
chatSendError(
'socket_disconnected',
'Realtime socket is not connected — responses cannot be delivered without a client ID.'
)
);
if (!sendDecision.shouldSend) {
const blockedFeedback = getComposerBlockedSendFeedback(sendDecision.blockReason);
if (blockedFeedback?.showLimitModal) {
setShowLimitModal(true);
}
if (blockedFeedback) {
setSendError(chatSendError(blockedFeedback.error.code, blockedFeedback.error.message));
}
return;
}
const sendingThreadId = selectedThreadId;
if (!sendingThreadId) return;
const userMessage: ThreadMessage = {
id: `msg_${globalThis.crypto.randomUUID()}`,
content: trimmed,
@@ -1624,6 +1630,8 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
{/* Voice input mic hidden per #717 (inputMode='voice' path retained). */}
</div>
<button
aria-label="Send message"
title="Send message"
onClick={() => {
void handleSendMessage();
}}
@@ -13,6 +13,8 @@ import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { threadApi } from '../../services/api/threadApi';
import { chatSend } from '../../services/chatService';
import chatRuntimeReducer from '../../store/chatRuntimeSlice';
import socketReducer from '../../store/socketSlice';
import threadReducer from '../../store/threadSlice';
@@ -74,6 +76,11 @@ vi.mock('../../services/api/threadApi', () => ({
vi.mock('../../hooks/useUsageState', () => ({ useUsageState: mockUseUsageState }));
vi.mock('../../store/socketSelectors', () => ({
selectSocketStatus: (state: { socket?: { byUser?: Record<string, { status: string }> } }) =>
state.socket?.byUser?.__pending__?.status ?? 'disconnected',
}));
// useStickToBottom returns refs; mock it so layout-effects don't fire in jsdom.
vi.mock('../../hooks/useStickToBottom', () => ({
useStickToBottom: vi.fn(() => ({ containerRef: { current: null }, endRef: { current: null } })),
@@ -162,6 +169,69 @@ const emptyThreadState = {
messagesError: null,
};
function selectedThreadState(thread: Thread) {
return {
...emptyThreadState,
threads: [thread],
selectedThreadId: thread.id,
messagesByThreadId: { [thread.id]: [] },
messages: [],
};
}
function socketState(status: 'connected' | 'disconnected') {
return {
byUser: { __pending__: { status, socketId: status === 'connected' ? 'socket-1' : null } },
};
}
async function renderSelectedConversation(
options: { isAtLimit?: boolean; socketStatus?: 'connected' | 'disconnected' } = {}
) {
const thread = makeThread({ id: 'send-thread', title: 'Send Thread' });
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
mockUseUsageState.mockReturnValue({
teamUsage: null,
currentPlan: null,
currentTier: 'FREE' as const,
isFreeTier: true,
usagePct10h: options.isAtLimit ? 1 : 0,
usagePct7d: options.isAtLimit ? 1 : 0,
isNearLimit: Boolean(options.isAtLimit),
isAtLimit: Boolean(options.isAtLimit),
isRateLimited: Boolean(options.isAtLimit),
isBudgetExhausted: false,
shouldShowBudgetCompletedMessage: false,
isLoading: false,
refresh: vi.fn(),
});
let renderedStore: ReturnType<typeof buildStore> | undefined;
await act(async () => {
renderedStore = await renderConversations({
thread: selectedThreadState(thread),
socket: socketState(options.socketStatus ?? 'connected'),
});
});
const textarea = await screen.findByPlaceholderText('Type a message...');
return { store: renderedStore, textarea, thread };
}
async function submitComposerText(textarea: HTMLElement, text: string) {
await act(async () => {
fireEvent.change(textarea, { target: { value: text } });
});
await waitFor(() => {
expect(textarea).toHaveValue(text);
expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
});
}
// ── Tests ──────────────────────────────────────────────────────────────────
describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
@@ -348,7 +418,6 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
});
// createNewThread was called — verifies line 919 callback executed
const { threadApi } = await import('../../services/api/threadApi');
expect(threadApi.createNewThread).toHaveBeenCalled();
});
@@ -372,7 +441,6 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
});
// createNewThread was called — verifies line 1061 callback executed
const { threadApi } = await import('../../services/api/threadApi');
expect(threadApi.createNewThread).toHaveBeenCalled();
});
@@ -553,4 +621,53 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
// isRateLimited=true, shouldShowBudgetCompletedMessage=false → rate-limit branch (line 1437)
expect(screen.getByText(/10-hour rate limit reached/i)).toBeInTheDocument();
});
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('shows the usage-limit modal instead of sending when the account is at limit', async () => {
const { textarea } = await renderSelectedConversation({ isAtLimit: true });
await submitComposerText(textarea, 'hello at limit');
await waitFor(() => {
expect(screen.getByText('Usage Limit Reached')).toBeInTheDocument();
});
expect(screen.getByText(/Usage limit reached/i)).toBeInTheDocument();
expect(chatSend).not.toHaveBeenCalled();
});
it('persists a local user message and sends through chat service for valid input', async () => {
const { textarea, thread } = await renderSelectedConversation();
await submitComposerText(textarea, ' hello cloud ');
await waitFor(() => {
expect(threadApi.appendMessage).toHaveBeenCalledWith(
thread.id,
expect.objectContaining({ content: 'hello cloud', sender: 'user', type: 'text' })
);
});
expect(chatSend).toHaveBeenCalledWith({
threadId: thread.id,
message: 'hello cloud',
model: 'reasoning-v1',
});
});
});
@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest';
import {
evaluateComposerSend,
getComposerBlockedSendFeedback,
handleComposerSlashCommand,
} from './composerSendDecision';
describe('evaluateComposerSend', () => {
it('blocks empty input', () => {
const decision = evaluateComposerSend({
rawText: ' ',
selectedThreadId: 'thread-1',
composerInteractionBlocked: false,
isAtLimit: false,
socketStatus: 'connected',
});
expect(decision).toEqual({ shouldSend: false, trimmedText: '', blockReason: 'empty_input' });
});
it('blocks usage limit', () => {
const decision = evaluateComposerSend({
rawText: 'hello',
selectedThreadId: 'thread-1',
composerInteractionBlocked: false,
isAtLimit: true,
socketStatus: 'connected',
});
expect(decision.blockReason).toBe('usage_limit_reached');
expect(decision.shouldSend).toBe(false);
});
it('blocks when no thread is selected', () => {
const decision = evaluateComposerSend({
rawText: 'hello',
selectedThreadId: null,
composerInteractionBlocked: false,
isAtLimit: false,
socketStatus: 'connected',
});
expect(decision.blockReason).toBe('missing_thread');
expect(decision.shouldSend).toBe(false);
});
it('blocks while composer interaction is disabled', () => {
const decision = evaluateComposerSend({
rawText: 'hello',
selectedThreadId: 'thread-1',
composerInteractionBlocked: true,
isAtLimit: false,
socketStatus: 'connected',
});
expect(decision.blockReason).toBe('composer_blocked');
expect(decision.shouldSend).toBe(false);
});
it('blocks when socket is disconnected', () => {
const decision = evaluateComposerSend({
rawText: 'hello',
selectedThreadId: 'thread-1',
composerInteractionBlocked: false,
isAtLimit: false,
socketStatus: 'disconnected',
});
expect(decision.blockReason).toBe('socket_disconnected');
expect(decision.shouldSend).toBe(false);
});
it('allows send path setup for valid chat send input', () => {
const decision = evaluateComposerSend({
rawText: ' hello ',
selectedThreadId: 'thread-1',
composerInteractionBlocked: false,
isAtLimit: false,
socketStatus: 'connected',
});
expect(decision).toEqual({ shouldSend: true, trimmedText: 'hello' });
});
});
describe('handleComposerSlashCommand', () => {
it('consumes /new and blocks thread reset when welcome lock is active', () => {
expect(handleComposerSlashCommand('/new', true)).toEqual({
kind: 'new_or_clear',
blockedByWelcomeLock: true,
});
});
it('consumes /clear when welcome lock is inactive', () => {
expect(handleComposerSlashCommand('/CLEAR', false)).toEqual({
kind: 'new_or_clear',
blockedByWelcomeLock: false,
});
});
it('ignores normal chat text', () => {
expect(handleComposerSlashCommand('hello', false)).toEqual({ kind: 'not_handled' });
});
});
describe('getComposerBlockedSendFeedback', () => {
it('returns modal and error feedback for usage-limit blocking', () => {
expect(getComposerBlockedSendFeedback('usage_limit_reached')).toEqual({
showLimitModal: true,
error: {
code: 'usage_limit_reached',
message: 'Usage limit reached. Upgrade or wait for reset.',
},
});
});
it('returns send error feedback for socket-disconnected blocking', () => {
expect(getComposerBlockedSendFeedback('socket_disconnected')).toEqual({
showLimitModal: false,
error: {
code: 'socket_disconnected',
message:
'Realtime socket is not connected — responses cannot be delivered without a client ID.',
},
});
});
it('ignores block reasons that do not surface user feedback', () => {
expect(getComposerBlockedSendFeedback('empty_input')).toBeNull();
expect(getComposerBlockedSendFeedback(undefined)).toBeNull();
});
});
@@ -0,0 +1,93 @@
export type ComposerSendBlockReason =
| 'empty_input'
| 'missing_thread'
| 'composer_blocked'
| 'usage_limit_reached'
| 'socket_disconnected';
export type SlashCommandDecision =
| { kind: 'new_or_clear'; blockedByWelcomeLock: boolean }
| { kind: 'not_handled' };
export interface ComposerSendDecisionArgs {
rawText: string;
selectedThreadId: string | null;
composerInteractionBlocked: boolean;
isAtLimit: boolean;
socketStatus: string;
}
export interface ComposerSendDecision {
shouldSend: boolean;
trimmedText: string;
blockReason?: ComposerSendBlockReason;
}
export interface ComposerBlockedSendFeedback {
showLimitModal: boolean;
error: { code: 'usage_limit_reached' | 'socket_disconnected'; message: string };
}
export const handleComposerSlashCommand = (
command: string,
welcomeLocked: boolean
): SlashCommandDecision => {
const cmd = command.toLowerCase();
if (cmd === '/new' || cmd === '/clear') {
return { kind: 'new_or_clear', blockedByWelcomeLock: welcomeLocked };
}
return { kind: 'not_handled' };
};
export const evaluateComposerSend = (args: ComposerSendDecisionArgs): ComposerSendDecision => {
const trimmedText = args.rawText.trim();
if (!trimmedText) {
return { shouldSend: false, trimmedText, blockReason: 'empty_input' };
}
if (!args.selectedThreadId) {
return { shouldSend: false, trimmedText, blockReason: 'missing_thread' };
}
if (args.composerInteractionBlocked) {
return { shouldSend: false, trimmedText, blockReason: 'composer_blocked' };
}
if (args.isAtLimit) {
return { shouldSend: false, trimmedText, blockReason: 'usage_limit_reached' };
}
if (args.socketStatus !== 'connected') {
return { shouldSend: false, trimmedText, blockReason: 'socket_disconnected' };
}
return { shouldSend: true, trimmedText };
};
export const getComposerBlockedSendFeedback = (
blockReason: ComposerSendBlockReason | undefined
): ComposerBlockedSendFeedback | null => {
if (blockReason === 'usage_limit_reached') {
return {
showLimitModal: true,
error: {
code: 'usage_limit_reached',
message: 'Usage limit reached. Upgrade or wait for reset.',
},
};
}
if (blockReason === 'socket_disconnected') {
return {
showLimitModal: false,
error: {
code: 'socket_disconnected',
message:
'Realtime socket is not connected — responses cannot be delivered without a client ID.',
},
};
}
return null;
};