chat: redesign chat input stop/send buttons

- stop button replaces send button in footer instead of overlay
- floating queue button above stop when input has content
- buttons same size, stop icon larger for emphasis
This commit is contained in:
Bohdan Triapitsyn
2026-02-01 22:43:46 +02:00
parent b441d27e99
commit d846f039e6
3 changed files with 141 additions and 57 deletions
+93 -31
View File
@@ -445,6 +445,16 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
};
// Primary action for send button - respects queue mode setting
const handlePrimaryAction = React.useCallback(() => {
const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle';
if (queueModeEnabled && canQueue) {
handleQueueMessage();
} else {
void handleSubmit();
}
}, [hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage, handleSubmit]);
// Keep a ref to handleSubmit for auto-send effect
const handleSubmitRef = React.useRef(handleSubmit);
handleSubmitRef.current = handleSubmit;
@@ -1075,30 +1085,15 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const footerGapClass = 'gap-x-1.5 gap-y-0';
const isVSCode = isVSCodeRuntime();
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5');
const footerHeightClass = isMobile ? 'h-9 w-9' : (isVSCode ? 'h-[22px] w-[22px]' : 'h-7 w-7');
const buttonSizeClass = isMobile ? 'h-8 w-8' : (isVSCode ? 'h-5 w-5' : 'h-6 w-6');
const sendIconSizeClass = isMobile ? 'h-4 w-4' : (isVSCode ? 'h-3.5 w-3.5' : 'h-4 w-4');
const stopIconSizeClass = isMobile ? 'h-6 w-6' : (isVSCode ? 'h-4 w-4' : 'h-5 w-5');
const iconSizeClass = isMobile ? 'h-5 w-5' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]');
const iconButtonBaseClass = cn(
footerHeightClass,
'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0'
);
const iconButtonBaseClass = 'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0';
const stopButton = canAbort ? (
<button
type="button"
onClick={handleAbort}
className={cn(
iconButtonBaseClass,
'absolute right-2 top-2 z-10',
'text-[var(--status-error)] hover:text-[var(--status-error)]'
)}
aria-label="Stop generating"
>
<StopIcon className={cn(iconSizeClass)} />
</button>
) : null;
const actionButton = (
// Send button - respects queue mode setting
const sendButton = (
<button
type={isMobile ? 'button' : 'submit'}
disabled={!canSend || (!currentSessionId && !newSessionDraftOpen)}
@@ -1114,7 +1109,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
sendTriggeredByPointerDownRef.current = true;
event.preventDefault();
event.stopPropagation();
void handleSubmit();
handlePrimaryAction();
}}
onClick={(event) => {
if (!isMobile) {
@@ -1127,20 +1122,90 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
event.preventDefault();
void handleSubmit();
handlePrimaryAction();
}}
className={cn(
iconButtonBaseClass,
buttonSizeClass,
canSend && (currentSessionId || newSessionDraftOpen)
? 'text-primary hover:text-primary'
: 'opacity-30'
)}
aria-label="Send message"
>
<RiSendPlane2Line className={cn(iconSizeClass)} />
<RiSendPlane2Line className={cn(sendIconSizeClass)} />
</button>
);
// Queue button for adding message to queue while working
const queueButton = (
<button
type="button"
disabled={!hasContent || !currentSessionId}
onPointerDownCapture={(event) => {
if (!isMobile || event.pointerType !== 'touch') {
return;
}
if (!hasContent || !currentSessionId) {
return;
}
sendTriggeredByPointerDownRef.current = true;
event.preventDefault();
event.stopPropagation();
handleQueueMessage();
}}
onClick={(event) => {
if (isMobile) {
if (sendTriggeredByPointerDownRef.current) {
sendTriggeredByPointerDownRef.current = false;
return;
}
event.preventDefault();
}
handleQueueMessage();
}}
className={cn(
iconButtonBaseClass,
buttonSizeClass,
'absolute bottom-full left-1/2 -translate-x-1/2 mb-1',
hasContent && currentSessionId
? 'text-primary hover:text-primary'
: 'opacity-30'
)}
aria-label="Queue message"
>
<RiSendPlane2Line className={cn(sendIconSizeClass, '-rotate-90')} />
</button>
);
// Stop button replaces send button when working
const stopButton = (
<button
type="button"
onClick={handleAbort}
className={cn(
iconButtonBaseClass,
buttonSizeClass,
'text-[var(--status-error)] hover:text-[var(--status-error)]'
)}
aria-label="Stop generating"
>
<StopIcon className={cn(stopIconSizeClass)} />
</button>
);
// Action buttons area: either send button, or stop (+ optional queue button floating above)
const actionButtons = canAbort ? (
<div className="relative">
{hasContent && queueButton}
{stopButton}
</div>
) : (
sendButton
);
const attachmentMenu = (
<>
<input
@@ -1256,7 +1321,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
return (
<form
onSubmit={handleSubmit}
onSubmit={(e) => { e.preventDefault(); handlePrimaryAction(); }}
className={cn(
"relative pt-0 pb-4",
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
@@ -1327,8 +1392,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
backgroundColor: currentTheme?.colors?.surface?.subtle,
}}
>
{stopButton}
{}
{showCommandAutocomplete && (
<CommandAutocomplete
ref={commandRef}
@@ -1380,8 +1443,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
outerClassName="focus-within:ring-0"
className={cn(
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent',
isMobile ? "py-2.5" : "pt-4 pb-2",
canAbort && 'pr-10'
isMobile ? "py-2.5" : "pt-4 pb-2"
)}
style={{
flex: 'none',
@@ -1414,7 +1476,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
<StatusChip onClick={handleOpenMobileControls} className="min-w-0" />
</div>
<div className="flex-shrink-0">
{actionButton}
{actionButtons}
</div>
</div>
<ModelControls
@@ -1438,7 +1500,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
</div>
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
<ModelControls className={cn('flex-1 min-w-0 justify-end')} />
{actionButton}
{actionButtons}
</div>
</>
)}
+24 -13
View File
@@ -66,6 +66,8 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useContextStore } from '@/stores/contextStore';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore } from '@/stores/messageQueueStore';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
@@ -358,6 +360,9 @@ export const FilesView: React.FC = () => {
const setMainTabGuard = useUIStore((state) => state.setMainTabGuard);
const inputBarOffset = useUIStore((state) => state.inputBarOffset);
const isKeyboardOpen = useUIStore((state) => state.isKeyboardOpen);
const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled);
const addToQueue = useMessageQueueStore((state) => state.addToQueue);
const { phase: sessionPhase } = useCurrentSessionActivity();
// Global mouseup to end drag selection
@@ -467,19 +472,25 @@ export const FilesView: React.FC = () => {
setLineSelection(null);
setActiveMainTab('chat');
void sendMessage(
message,
effectiveProviderId,
effectiveModelId,
sessionAgent,
undefined,
undefined,
undefined,
effectiveVariant
).catch((e) => {
console.error('Failed to send comment', e);
});
}, [lineSelection, commentText, selectedFile, fileContent, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, extractSelectedCode, sendMessage, setActiveMainTab, getSessionAgentSelection, getAgentModelForSession, getAgentModelVariantForSession]);
// Check if should queue instead of send
const canQueue = sessionPhase !== 'idle';
if (queueModeEnabled && canQueue) {
addToQueue(currentSessionId, { content: message });
} else {
void sendMessage(
message,
effectiveProviderId,
effectiveModelId,
sessionAgent,
undefined,
undefined,
undefined,
effectiveVariant
).catch((e) => {
console.error('Failed to send comment', e);
});
}
}, [lineSelection, commentText, selectedFile, fileContent, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, extractSelectedCode, sendMessage, setActiveMainTab, getSessionAgentSelection, getAgentModelForSession, getAgentModelVariantForSession, queueModeEnabled, sessionPhase, addToQueue]);
const mapDirectoryEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileNode[] => {
const nodes = entries
@@ -15,6 +15,8 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useContextStore } from '@/stores/contextStore';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore } from '@/stores/messageQueueStore';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { useDeviceInfo } from '@/lib/device';
import { cn, getModifierLabel } from '@/lib/utils';
@@ -188,6 +190,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const getSessionAgentSelection = useContextStore(state => state.getSessionAgentSelection);
const getAgentModelForSession = useContextStore(state => state.getAgentModelForSession);
const getAgentModelVariantForSession = useContextStore(state => state.getAgentModelVariantForSession);
const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled);
const addToQueue = useMessageQueueStore(state => state.addToQueue);
const { phase: sessionPhase } = useCurrentSessionActivity();
// Update main content metrics on resize
useEffect(() => {
@@ -303,19 +308,25 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
setSelection(null);
setActiveMainTab('chat');
void sendMessage(
message,
effectiveProviderId,
effectiveModelId,
sessionAgent,
undefined,
undefined,
undefined,
effectiveVariant
).catch((e) => {
console.error('Failed to send comment', e);
});
}, [selection, commentText, original, modified, fileName, language, sendMessage, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, setActiveMainTab, getSessionAgentSelection, getAgentModelForSession, getAgentModelVariantForSession]);
// Check if should queue instead of send
const canQueue = sessionPhase !== 'idle';
if (queueModeEnabled && canQueue) {
addToQueue(currentSessionId, { content: message });
} else {
void sendMessage(
message,
effectiveProviderId,
effectiveModelId,
sessionAgent,
undefined,
undefined,
undefined,
effectiveVariant
).catch((e) => {
console.error('Failed to send comment', e);
});
}
}, [selection, commentText, original, modified, fileName, language, sendMessage, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, setActiveMainTab, getSessionAgentSelection, getAgentModelForSession, getAgentModelVariantForSession, queueModeEnabled, sessionPhase, addToQueue]);
ensurePierreThemeRegistered(lightTheme);
ensurePierreThemeRegistered(darkTheme);