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 {
|
interface ChatInputProps {
|
||||||
onSend: (text: string, attachments?: ChatAttachment[]) => void;
|
onSend: (text: string, attachments?: ChatAttachment[]) => void;
|
||||||
|
onStop?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
sending?: 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 [input, setInput] = useState('');
|
||||||
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isComposingRef = useRef(false);
|
||||||
|
|
||||||
// Auto-resize textarea
|
// Auto-resize textarea
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,6 +87,7 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const canSend = (input.trim() || attachments.length > 0) && !disabled && !sending;
|
const canSend = (input.trim() || attachments.length > 0) && !disabled && !sending;
|
||||||
|
const canStop = sending && !disabled && !!onStop;
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
if (!canSend) return;
|
if (!canSend) return;
|
||||||
@@ -96,9 +99,18 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
|
|||||||
}
|
}
|
||||||
}, [input, attachments, canSend, onSend]);
|
}, [input, attachments, canSend, onSend]);
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
if (!canStop) return;
|
||||||
|
onStop?.();
|
||||||
|
}, [canStop, onStop]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
const nativeEvent = e.nativeEvent as KeyboardEvent;
|
||||||
|
if (isComposingRef.current || nativeEvent.isComposing || nativeEvent.keyCode === 229) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
handleSend();
|
||||||
}
|
}
|
||||||
@@ -221,6 +233,12 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onCompositionStart={() => {
|
||||||
|
isComposingRef.current = true;
|
||||||
|
}}
|
||||||
|
onCompositionEnd={() => {
|
||||||
|
isComposingRef.current = false;
|
||||||
|
}}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
placeholder={disabled ? 'Gateway not connected...' : 'Message (Enter to send, Shift+Enter for new line)'}
|
placeholder={disabled ? 'Gateway not connected...' : 'Message (Enter to send, Shift+Enter for new line)'}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -231,11 +249,12 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
|
|||||||
|
|
||||||
{/* Send Button */}
|
{/* Send Button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={sending ? handleStop : handleSend}
|
||||||
disabled={!canSend}
|
disabled={sending ? !canStop : !canSend}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="shrink-0 h-[44px] w-[44px]"
|
className="shrink-0 h-[44px] w-[44px]"
|
||||||
variant={sending ? 'destructive' : 'default'}
|
variant={sending ? 'destructive' : 'default'}
|
||||||
|
title={sending ? 'Stop' : 'Send'}
|
||||||
>
|
>
|
||||||
{sending ? (
|
{sending ? (
|
||||||
<Square className="h-4 w-4" />
|
<Square className="h-4 w-4" />
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function Chat() {
|
|||||||
const loadHistory = useChatStore((s) => s.loadHistory);
|
const loadHistory = useChatStore((s) => s.loadHistory);
|
||||||
const loadSessions = useChatStore((s) => s.loadSessions);
|
const loadSessions = useChatStore((s) => s.loadSessions);
|
||||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||||
|
const abortRun = useChatStore((s) => s.abortRun);
|
||||||
const clearError = useChatStore((s) => s.clearError);
|
const clearError = useChatStore((s) => s.clearError);
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -145,6 +146,7 @@ export function Chat() {
|
|||||||
{/* Input Area */}
|
{/* Input Area */}
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={sendMessage}
|
onSend={sendMessage}
|
||||||
|
onStop={abortRun}
|
||||||
disabled={!isGatewayRunning}
|
disabled={!isGatewayRunning}
|
||||||
sending={sending}
|
sending={sending}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -63,12 +63,41 @@ interface ChatState {
|
|||||||
newSession: () => void;
|
newSession: () => void;
|
||||||
loadHistory: () => Promise<void>;
|
loadHistory: () => Promise<void>;
|
||||||
sendMessage: (text: string, attachments?: { type: string; mimeType: string; fileName: string; content: string }[]) => 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;
|
handleChatEvent: (event: Record<string, unknown>) => void;
|
||||||
toggleThinking: () => void;
|
toggleThinking: () => void;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
clearError: () => 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 ────────────────────────────────────────────────────────
|
// ── Store ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const useChatStore = create<ChatState>((set, get) => ({
|
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 ──
|
// ── Handle incoming chat events from Gateway ──
|
||||||
|
|
||||||
handleChatEvent: (event: Record<string, unknown>) => {
|
handleChatEvent: (event: Record<string, unknown>) => {
|
||||||
@@ -282,20 +328,32 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
// Message complete - add to history and clear streaming
|
// Message complete - add to history and clear streaming
|
||||||
const finalMsg = event.message as RawMessage | undefined;
|
const finalMsg = event.message as RawMessage | undefined;
|
||||||
if (finalMsg) {
|
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) => {
|
set((s) => {
|
||||||
// Check if message already exists (prevent duplicates)
|
// Check if message already exists (prevent duplicates)
|
||||||
const alreadyExists = s.messages.some(m => m.id === msgId);
|
const alreadyExists = s.messages.some(m => m.id === msgId);
|
||||||
if (alreadyExists) {
|
if (alreadyExists) {
|
||||||
// Just clear streaming state, don't add duplicate
|
// Just clear streaming state, don't add duplicate
|
||||||
return {
|
return toolOnly ? {
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
} : {
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
streamingMessage: null,
|
streamingMessage: null,
|
||||||
sending: false,
|
sending: false,
|
||||||
activeRunId: null,
|
activeRunId: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return toolOnly ? {
|
||||||
|
messages: [...s.messages, {
|
||||||
|
...finalMsg,
|
||||||
|
role: finalMsg.role || 'assistant',
|
||||||
|
id: msgId,
|
||||||
|
}],
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
} : {
|
||||||
messages: [...s.messages, {
|
messages: [...s.messages, {
|
||||||
...finalMsg,
|
...finalMsg,
|
||||||
role: finalMsg.role || 'assistant',
|
role: finalMsg.role || 'assistant',
|
||||||
|
|||||||
Reference in New Issue
Block a user