misc: chat stop button and tool typing indicator and prevent sending message during composition (#37)
This commit is contained in:
@@ -19,6 +19,7 @@ export interface ChatAttachment {
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (text: string, attachments?: ChatAttachment[]) => void;
|
||||
onStop?: () => void;
|
||||
disabled?: boolean;
|
||||
sending?: boolean;
|
||||
}
|
||||
@@ -54,11 +55,12 @@ function fileToAttachment(file: File): Promise<ChatAttachment> {
|
||||
});
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled = false, sending = false }: ChatInputProps) {
|
||||
export function ChatInput({ onSend, onStop, disabled = false, sending = false }: ChatInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
@@ -85,6 +87,7 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
|
||||
}, []);
|
||||
|
||||
const canSend = (input.trim() || attachments.length > 0) && !disabled && !sending;
|
||||
const canStop = sending && !disabled && !!onStop;
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (!canSend) return;
|
||||
@@ -96,9 +99,18 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
|
||||
}
|
||||
}, [input, attachments, canSend, onSend]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (!canStop) return;
|
||||
onStop?.();
|
||||
}, [canStop, onStop]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
const nativeEvent = e.nativeEvent as KeyboardEvent;
|
||||
if (isComposingRef.current || nativeEvent.isComposing || nativeEvent.keyCode === 229) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
@@ -221,6 +233,12 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => {
|
||||
isComposingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
isComposingRef.current = false;
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
placeholder={disabled ? 'Gateway not connected...' : 'Message (Enter to send, Shift+Enter for new line)'}
|
||||
disabled={disabled}
|
||||
@@ -231,11 +249,12 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
|
||||
|
||||
{/* Send Button */}
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!canSend}
|
||||
onClick={sending ? handleStop : handleSend}
|
||||
disabled={sending ? !canStop : !canSend}
|
||||
size="icon"
|
||||
className="shrink-0 h-[44px] w-[44px]"
|
||||
variant={sending ? 'destructive' : 'default'}
|
||||
title={sending ? 'Stop' : 'Send'}
|
||||
>
|
||||
{sending ? (
|
||||
<Square className="h-4 w-4" />
|
||||
|
||||
@@ -28,6 +28,7 @@ export function Chat() {
|
||||
const loadHistory = useChatStore((s) => s.loadHistory);
|
||||
const loadSessions = useChatStore((s) => s.loadSessions);
|
||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||
const abortRun = useChatStore((s) => s.abortRun);
|
||||
const clearError = useChatStore((s) => s.clearError);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -145,6 +146,7 @@ export function Chat() {
|
||||
{/* Input Area */}
|
||||
<ChatInput
|
||||
onSend={sendMessage}
|
||||
onStop={abortRun}
|
||||
disabled={!isGatewayRunning}
|
||||
sending={sending}
|
||||
/>
|
||||
|
||||
@@ -63,12 +63,41 @@ interface ChatState {
|
||||
newSession: () => void;
|
||||
loadHistory: () => Promise<void>;
|
||||
sendMessage: (text: string, attachments?: { type: string; mimeType: string; fileName: string; content: string }[]) => Promise<void>;
|
||||
abortRun: () => Promise<void>;
|
||||
handleChatEvent: (event: Record<string, unknown>) => void;
|
||||
toggleThinking: () => void;
|
||||
refresh: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
function isToolOnlyMessage(message: RawMessage | undefined): boolean {
|
||||
if (!message) return false;
|
||||
if (message.role === 'toolresult') return true;
|
||||
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) return false;
|
||||
|
||||
let hasTool = false;
|
||||
let hasText = false;
|
||||
let hasNonToolContent = false;
|
||||
|
||||
for (const block of content as ContentBlock[]) {
|
||||
if (block.type === 'tool_use' || block.type === 'tool_result') {
|
||||
hasTool = true;
|
||||
continue;
|
||||
}
|
||||
if (block.type === 'text' && block.text && block.text.trim()) {
|
||||
hasText = true;
|
||||
continue;
|
||||
}
|
||||
if (block.type === 'image' || block.type === 'thinking') {
|
||||
hasNonToolContent = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasTool && !hasText && !hasNonToolContent;
|
||||
}
|
||||
|
||||
// ── Store ────────────────────────────────────────────────────────
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
@@ -260,6 +289,23 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// ── Abort active run ──
|
||||
|
||||
abortRun: async () => {
|
||||
const { currentSessionKey } = get();
|
||||
set({ sending: false, streamingText: '', streamingMessage: null });
|
||||
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'chat.abort',
|
||||
{ sessionKey: currentSessionKey },
|
||||
);
|
||||
} catch (err) {
|
||||
set({ error: String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
// ── Handle incoming chat events from Gateway ──
|
||||
|
||||
handleChatEvent: (event: Record<string, unknown>) => {
|
||||
@@ -282,20 +328,32 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
// Message complete - add to history and clear streaming
|
||||
const finalMsg = event.message as RawMessage | undefined;
|
||||
if (finalMsg) {
|
||||
const msgId = finalMsg.id || `run-${runId}`;
|
||||
const toolOnly = isToolOnlyMessage(finalMsg);
|
||||
const msgId = finalMsg.id || (toolOnly ? `run-${runId}-tool-${Date.now()}` : `run-${runId}`);
|
||||
set((s) => {
|
||||
// Check if message already exists (prevent duplicates)
|
||||
const alreadyExists = s.messages.some(m => m.id === msgId);
|
||||
if (alreadyExists) {
|
||||
// Just clear streaming state, don't add duplicate
|
||||
return {
|
||||
return toolOnly ? {
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
} : {
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
return toolOnly ? {
|
||||
messages: [...s.messages, {
|
||||
...finalMsg,
|
||||
role: finalMsg.role || 'assistant',
|
||||
id: msgId,
|
||||
}],
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
} : {
|
||||
messages: [...s.messages, {
|
||||
...finalMsg,
|
||||
role: finalMsg.role || 'assistant',
|
||||
|
||||
Reference in New Issue
Block a user