Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 40 additions & 32 deletions app/src/pages/Conversations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,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,
Expand Down Expand Up @@ -480,30 +485,36 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
}, [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') {
Expand All @@ -512,24 +523,19 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
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,
Expand Down Expand Up @@ -1604,6 +1610,8 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
{/* Voice input mic hidden per #717 (inputMode='voice' path retained). */}
</div>
<button
aria-label="Send message"
title="Send message"
onClick={() => {
void handleSendMessage();
}}
Expand Down
121 changes: 119 additions & 2 deletions app/src/pages/__tests__/Conversations.render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 } })),
Expand Down Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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();
});

Expand All @@ -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();
});

Expand Down Expand Up @@ -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',
});
});
});
Loading
Loading