From 3e6558492e0668888cf5c6f94b068aa1370a33f5 Mon Sep 17 00:00:00 2001 From: Jwalin Shah Date: Tue, 5 May 2026 08:12:48 -0700 Subject: [PATCH 1/6] Extract conversations composer send decision logic --- app/src/pages/Conversations.tsx | 65 +++++++++--------- .../composerSendDecision.test.ts | 68 +++++++++++++++++++ .../conversations/composerSendDecision.ts | 66 ++++++++++++++++++ 3 files changed, 168 insertions(+), 31 deletions(-) create mode 100644 app/src/pages/conversations/composerSendDecision.test.ts create mode 100644 app/src/pages/conversations/composerSendDecision.ts diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 6a5ba541e..10c0f03db 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -47,6 +47,7 @@ import { } from '../utils/tauriCommands'; import { formatTimelineEntry } from '../utils/toolTimelineFormatting'; import { AgentMessageBubble, BubbleMarkdown } from './conversations/components/AgentMessageBubble'; +import { evaluateComposerSend, handleComposerSlashCommand } from './conversations/composerSendDecision'; import { CitationChips, type MessageCitation } from './conversations/components/CitationChips'; import { LimitPill } from './conversations/components/LimitPill'; import { ToolTimelineBlock } from './conversations/components/ToolTimelineBlock'; @@ -439,48 +440,50 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { }, [inputMode, rustChat]); const handleSlashCommand = (command: string): boolean => { - const cmd = command.toLowerCase(); - if (cmd === '/new' || cmd === '/clear') { - // 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, welcomeLocked); + if (decision.kind === 'not_handled') return false; + + // 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`. + setInputValue(''); + if (decision.blockedByWelcomeLock) return true; + void handleCreateNewThread(); + return true; }; const handleSendMessage = async (text?: string) => { const normalized = text ?? inputValue; - const trimmed = normalized.trim(); - - if (!trimmed || !selectedThreadId || composerInteractionBlocked) return; + const sendDecision = evaluateComposerSend({ + rawText: normalized, + selectedThreadId, + composerInteractionBlocked, + isAtLimit, + socketStatus, + }); + const trimmed = sendDecision.trimmedText; if (handleSlashCommand(trimmed)) return; - 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) { + if (sendDecision.blockReason === 'usage_limit_reached') { + setShowLimitModal(true); + setSendError( + chatSendError('usage_limit_reached', 'Usage limit reached. Upgrade or wait for reset.') + ); + } else if (sendDecision.blockReason === 'socket_disconnected') { + setSendError( + chatSendError( + 'socket_disconnected', + 'Realtime socket is not connected — responses cannot be delivered without a client ID.' + ) + ); + } return; } const sendingThreadId = selectedThreadId; + if (!sendingThreadId) return; const userMessage: ThreadMessage = { id: `msg_${globalThis.crypto.randomUUID()}`, content: trimmed, diff --git a/app/src/pages/conversations/composerSendDecision.test.ts b/app/src/pages/conversations/composerSendDecision.test.ts new file mode 100644 index 000000000..99ff7a561 --- /dev/null +++ b/app/src/pages/conversations/composerSendDecision.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { evaluateComposerSend, 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 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, + }); + }); +}); diff --git a/app/src/pages/conversations/composerSendDecision.ts b/app/src/pages/conversations/composerSendDecision.ts new file mode 100644 index 000000000..a31ab2af6 --- /dev/null +++ b/app/src/pages/conversations/composerSendDecision.ts @@ -0,0 +1,66 @@ +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 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, + }; +}; From 23690d89fd74dd13d16211f05e3d92ac99b21581 Mon Sep 17 00:00:00 2001 From: Jwalin Shah Date: Tue, 5 May 2026 08:17:46 -0700 Subject: [PATCH 2/6] style: format composer send decision extraction --- app/src/pages/Conversations.tsx | 5 ++++- app/src/pages/conversations/composerSendDecision.test.ts | 6 +----- app/src/pages/conversations/composerSendDecision.ts | 9 ++------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 10c0f03db..13a22ccb8 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -47,10 +47,13 @@ import { } from '../utils/tauriCommands'; import { formatTimelineEntry } from '../utils/toolTimelineFormatting'; import { AgentMessageBubble, BubbleMarkdown } from './conversations/components/AgentMessageBubble'; -import { evaluateComposerSend, handleComposerSlashCommand } from './conversations/composerSendDecision'; import { CitationChips, type MessageCitation } from './conversations/components/CitationChips'; import { LimitPill } from './conversations/components/LimitPill'; import { ToolTimelineBlock } from './conversations/components/ToolTimelineBlock'; +import { + evaluateComposerSend, + handleComposerSlashCommand, +} from './conversations/composerSendDecision'; import { type AgentBubblePosition, buildAcceptedInlineCompletion, diff --git a/app/src/pages/conversations/composerSendDecision.test.ts b/app/src/pages/conversations/composerSendDecision.test.ts index 99ff7a561..906a361a4 100644 --- a/app/src/pages/conversations/composerSendDecision.test.ts +++ b/app/src/pages/conversations/composerSendDecision.test.ts @@ -12,11 +12,7 @@ describe('evaluateComposerSend', () => { socketStatus: 'connected', }); - expect(decision).toEqual({ - shouldSend: false, - trimmedText: '', - blockReason: 'empty_input', - }); + expect(decision).toEqual({ shouldSend: false, trimmedText: '', blockReason: 'empty_input' }); }); it('blocks usage limit', () => { diff --git a/app/src/pages/conversations/composerSendDecision.ts b/app/src/pages/conversations/composerSendDecision.ts index a31ab2af6..90725bf76 100644 --- a/app/src/pages/conversations/composerSendDecision.ts +++ b/app/src/pages/conversations/composerSendDecision.ts @@ -34,9 +34,7 @@ export const handleComposerSlashCommand = ( return { kind: 'not_handled' }; }; -export const evaluateComposerSend = ( - args: ComposerSendDecisionArgs -): ComposerSendDecision => { +export const evaluateComposerSend = (args: ComposerSendDecisionArgs): ComposerSendDecision => { const trimmedText = args.rawText.trim(); if (!trimmedText) { @@ -59,8 +57,5 @@ export const evaluateComposerSend = ( return { shouldSend: false, trimmedText, blockReason: 'socket_disconnected' }; } - return { - shouldSend: true, - trimmedText, - }; + return { shouldSend: true, trimmedText }; }; From 31bf54a33e434d572e4a32fbfdef74249909afee Mon Sep 17 00:00:00 2001 From: Jwalin Shah Date: Tue, 5 May 2026 08:25:04 -0700 Subject: [PATCH 3/6] fix: preserve composer slash command gating --- app/src/pages/Conversations.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 13a22ccb8..8ec6ac489 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -466,6 +466,14 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { }); const trimmed = sendDecision.trimmedText; + if ( + sendDecision.blockReason === 'empty_input' || + sendDecision.blockReason === 'missing_thread' || + sendDecision.blockReason === 'composer_blocked' + ) { + return; + } + if (handleSlashCommand(trimmed)) return; if (!sendDecision.shouldSend) { From fb9c0c74b387eb4082a078aa8cb5b71f7ba79e87 Mon Sep 17 00:00:00 2001 From: Jwalin Shah Date: Tue, 5 May 2026 15:58:16 -0700 Subject: [PATCH 4/6] test: cover composer send decisions --- app/src/pages/Conversations.tsx | 19 ++- .../__tests__/Conversations.render.test.tsx | 115 ++++++++++++++++++ .../composerSendDecision.test.ts | 71 ++++++++++- .../conversations/composerSendDecision.ts | 32 +++++ 4 files changed, 225 insertions(+), 12 deletions(-) diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 722221349..8644a07c1 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -59,6 +59,7 @@ import { LimitPill } from './conversations/components/LimitPill'; import { ToolTimelineBlock } from './conversations/components/ToolTimelineBlock'; import { evaluateComposerSend, + getComposerBlockedSendFeedback, handleComposerSlashCommand, } from './conversations/composerSendDecision'; import { @@ -521,18 +522,12 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { } if (!sendDecision.shouldSend) { - if (sendDecision.blockReason === 'usage_limit_reached') { + const blockedFeedback = getComposerBlockedSendFeedback(sendDecision.blockReason); + if (blockedFeedback?.showLimitModal) { setShowLimitModal(true); - setSendError( - chatSendError('usage_limit_reached', 'Usage limit reached. Upgrade or wait for reset.') - ); - } else if (sendDecision.blockReason === 'socket_disconnected') { - setSendError( - chatSendError( - 'socket_disconnected', - 'Realtime socket is not connected — responses cannot be delivered without a client ID.' - ) - ); + } + if (blockedFeedback) { + setSendError(chatSendError(blockedFeedback.error.code, blockedFeedback.error.message)); } return; } @@ -1613,6 +1608,8 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { {/* Voice input mic hidden per #717 (inputMode='voice' path retained). */}