mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
Extract composer send-gating logic from Conversations page (#1239)
Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
This commit is contained in:
co-authored by
Jwalin Shah
parent
bbdb823eda
commit
a08ec95338
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user