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:
@@ -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
|
// Keep a ref to handleSubmit for auto-send effect
|
||||||
const handleSubmitRef = React.useRef(handleSubmit);
|
const handleSubmitRef = React.useRef(handleSubmit);
|
||||||
handleSubmitRef.current = 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 footerGapClass = 'gap-x-1.5 gap-y-0';
|
||||||
const isVSCode = isVSCodeRuntime();
|
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 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 iconSizeClass = isMobile ? 'h-5 w-5' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]');
|
||||||
|
|
||||||
const iconButtonBaseClass = cn(
|
const iconButtonBaseClass = 'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0';
|
||||||
footerHeightClass,
|
|
||||||
'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0'
|
|
||||||
);
|
|
||||||
|
|
||||||
const stopButton = canAbort ? (
|
// Send button - respects queue mode setting
|
||||||
<button
|
const sendButton = (
|
||||||
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 = (
|
|
||||||
<button
|
<button
|
||||||
type={isMobile ? 'button' : 'submit'}
|
type={isMobile ? 'button' : 'submit'}
|
||||||
disabled={!canSend || (!currentSessionId && !newSessionDraftOpen)}
|
disabled={!canSend || (!currentSessionId && !newSessionDraftOpen)}
|
||||||
@@ -1114,7 +1109,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
sendTriggeredByPointerDownRef.current = true;
|
sendTriggeredByPointerDownRef.current = true;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
void handleSubmit();
|
handlePrimaryAction();
|
||||||
}}
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
@@ -1127,20 +1122,90 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void handleSubmit();
|
handlePrimaryAction();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
iconButtonBaseClass,
|
iconButtonBaseClass,
|
||||||
|
buttonSizeClass,
|
||||||
canSend && (currentSessionId || newSessionDraftOpen)
|
canSend && (currentSessionId || newSessionDraftOpen)
|
||||||
? 'text-primary hover:text-primary'
|
? 'text-primary hover:text-primary'
|
||||||
: 'opacity-30'
|
: 'opacity-30'
|
||||||
)}
|
)}
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
>
|
>
|
||||||
<RiSendPlane2Line className={cn(iconSizeClass)} />
|
<RiSendPlane2Line className={cn(sendIconSizeClass)} />
|
||||||
</button>
|
</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 = (
|
const attachmentMenu = (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -1256,7 +1321,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
return (
|
return (
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={(e) => { e.preventDefault(); handlePrimaryAction(); }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative pt-0 pb-4",
|
"relative pt-0 pb-4",
|
||||||
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
|
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,
|
backgroundColor: currentTheme?.colors?.surface?.subtle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{stopButton}
|
|
||||||
{}
|
|
||||||
{showCommandAutocomplete && (
|
{showCommandAutocomplete && (
|
||||||
<CommandAutocomplete
|
<CommandAutocomplete
|
||||||
ref={commandRef}
|
ref={commandRef}
|
||||||
@@ -1380,8 +1443,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
outerClassName="focus-within:ring-0"
|
outerClassName="focus-within:ring-0"
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent',
|
'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",
|
isMobile ? "py-2.5" : "pt-4 pb-2"
|
||||||
canAbort && 'pr-10'
|
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
flex: 'none',
|
flex: 'none',
|
||||||
@@ -1414,7 +1476,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
<StatusChip onClick={handleOpenMobileControls} className="min-w-0" />
|
<StatusChip onClick={handleOpenMobileControls} className="min-w-0" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
{actionButton}
|
{actionButtons}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ModelControls
|
<ModelControls
|
||||||
@@ -1438,7 +1500,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
</div>
|
</div>
|
||||||
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
|
<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')} />
|
<ModelControls className={cn('flex-1 min-w-0 justify-end')} />
|
||||||
{actionButton}
|
{actionButtons}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
|||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||||
|
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||||
@@ -358,6 +360,9 @@ export const FilesView: React.FC = () => {
|
|||||||
const setMainTabGuard = useUIStore((state) => state.setMainTabGuard);
|
const setMainTabGuard = useUIStore((state) => state.setMainTabGuard);
|
||||||
const inputBarOffset = useUIStore((state) => state.inputBarOffset);
|
const inputBarOffset = useUIStore((state) => state.inputBarOffset);
|
||||||
const isKeyboardOpen = useUIStore((state) => state.isKeyboardOpen);
|
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
|
// Global mouseup to end drag selection
|
||||||
@@ -467,19 +472,25 @@ export const FilesView: React.FC = () => {
|
|||||||
setLineSelection(null);
|
setLineSelection(null);
|
||||||
setActiveMainTab('chat');
|
setActiveMainTab('chat');
|
||||||
|
|
||||||
void sendMessage(
|
// Check if should queue instead of send
|
||||||
message,
|
const canQueue = sessionPhase !== 'idle';
|
||||||
effectiveProviderId,
|
if (queueModeEnabled && canQueue) {
|
||||||
effectiveModelId,
|
addToQueue(currentSessionId, { content: message });
|
||||||
sessionAgent,
|
} else {
|
||||||
undefined,
|
void sendMessage(
|
||||||
undefined,
|
message,
|
||||||
undefined,
|
effectiveProviderId,
|
||||||
effectiveVariant
|
effectiveModelId,
|
||||||
).catch((e) => {
|
sessionAgent,
|
||||||
console.error('Failed to send comment', e);
|
undefined,
|
||||||
});
|
undefined,
|
||||||
}, [lineSelection, commentText, selectedFile, fileContent, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, extractSelectedCode, sendMessage, setActiveMainTab, getSessionAgentSelection, getAgentModelForSession, getAgentModelVariantForSession]);
|
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 mapDirectoryEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileNode[] => {
|
||||||
const nodes = entries
|
const nodes = entries
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
|||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||||
|
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { cn, getModifierLabel } from '@/lib/utils';
|
import { cn, getModifierLabel } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -188,6 +190,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
const getSessionAgentSelection = useContextStore(state => state.getSessionAgentSelection);
|
const getSessionAgentSelection = useContextStore(state => state.getSessionAgentSelection);
|
||||||
const getAgentModelForSession = useContextStore(state => state.getAgentModelForSession);
|
const getAgentModelForSession = useContextStore(state => state.getAgentModelForSession);
|
||||||
const getAgentModelVariantForSession = useContextStore(state => state.getAgentModelVariantForSession);
|
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
|
// Update main content metrics on resize
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -303,19 +308,25 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
setSelection(null);
|
setSelection(null);
|
||||||
setActiveMainTab('chat');
|
setActiveMainTab('chat');
|
||||||
|
|
||||||
void sendMessage(
|
// Check if should queue instead of send
|
||||||
message,
|
const canQueue = sessionPhase !== 'idle';
|
||||||
effectiveProviderId,
|
if (queueModeEnabled && canQueue) {
|
||||||
effectiveModelId,
|
addToQueue(currentSessionId, { content: message });
|
||||||
sessionAgent,
|
} else {
|
||||||
undefined,
|
void sendMessage(
|
||||||
undefined,
|
message,
|
||||||
undefined,
|
effectiveProviderId,
|
||||||
effectiveVariant
|
effectiveModelId,
|
||||||
).catch((e) => {
|
sessionAgent,
|
||||||
console.error('Failed to send comment', e);
|
undefined,
|
||||||
});
|
undefined,
|
||||||
}, [selection, commentText, original, modified, fileName, language, sendMessage, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, setActiveMainTab, getSessionAgentSelection, getAgentModelForSession, getAgentModelVariantForSession]);
|
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(lightTheme);
|
||||||
ensurePierreThemeRegistered(darkTheme);
|
ensurePierreThemeRegistered(darkTheme);
|
||||||
|
|||||||
Reference in New Issue
Block a user