Skip to content

Commit 816a0e2

Browse files
authored
misc: chat stop button and tool typing indicator and prevent sending message during composition (#37)
1 parent 29d0db7 commit 816a0e2

File tree

3 files changed

+85
-6
lines changed

3 files changed

+85
-6
lines changed

src/pages/Chat/ChatInput.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface ChatAttachment {
1919

2020
interface ChatInputProps {
2121
onSend: (text: string, attachments?: ChatAttachment[]) => void;
22+
onStop?: () => void;
2223
disabled?: boolean;
2324
sending?: boolean;
2425
}
@@ -54,11 +55,12 @@ function fileToAttachment(file: File): Promise<ChatAttachment> {
5455
});
5556
}
5657

57-
export function ChatInput({ onSend, disabled = false, sending = false }: ChatInputProps) {
58+
export function ChatInput({ onSend, onStop, disabled = false, sending = false }: ChatInputProps) {
5859
const [input, setInput] = useState('');
5960
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
6061
const textareaRef = useRef<HTMLTextAreaElement>(null);
6162
const fileInputRef = useRef<HTMLInputElement>(null);
63+
const isComposingRef = useRef(false);
6264

6365
// Auto-resize textarea
6466
useEffect(() => {
@@ -85,6 +87,7 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
8587
}, []);
8688

8789
const canSend = (input.trim() || attachments.length > 0) && !disabled && !sending;
90+
const canStop = sending && !disabled && !!onStop;
8891

8992
const handleSend = useCallback(() => {
9093
if (!canSend) return;
@@ -96,9 +99,18 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
9699
}
97100
}, [input, attachments, canSend, onSend]);
98101

102+
const handleStop = useCallback(() => {
103+
if (!canStop) return;
104+
onStop?.();
105+
}, [canStop, onStop]);
106+
99107
const handleKeyDown = useCallback(
100108
(e: React.KeyboardEvent) => {
101109
if (e.key === 'Enter' && !e.shiftKey) {
110+
const nativeEvent = e.nativeEvent as KeyboardEvent;
111+
if (isComposingRef.current || nativeEvent.isComposing || nativeEvent.keyCode === 229) {
112+
return;
113+
}
102114
e.preventDefault();
103115
handleSend();
104116
}
@@ -221,6 +233,12 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
221233
value={input}
222234
onChange={(e) => setInput(e.target.value)}
223235
onKeyDown={handleKeyDown}
236+
onCompositionStart={() => {
237+
isComposingRef.current = true;
238+
}}
239+
onCompositionEnd={() => {
240+
isComposingRef.current = false;
241+
}}
224242
onPaste={handlePaste}
225243
placeholder={disabled ? 'Gateway not connected...' : 'Message (Enter to send, Shift+Enter for new line)'}
226244
disabled={disabled}
@@ -231,11 +249,12 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
231249

232250
{/* Send Button */}
233251
<Button
234-
onClick={handleSend}
235-
disabled={!canSend}
252+
onClick={sending ? handleStop : handleSend}
253+
disabled={sending ? !canStop : !canSend}
236254
size="icon"
237255
className="shrink-0 h-[44px] w-[44px]"
238256
variant={sending ? 'destructive' : 'default'}
257+
title={sending ? 'Stop' : 'Send'}
239258
>
240259
{sending ? (
241260
<Square className="h-4 w-4" />

src/pages/Chat/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export function Chat() {
2828
const loadHistory = useChatStore((s) => s.loadHistory);
2929
const loadSessions = useChatStore((s) => s.loadSessions);
3030
const sendMessage = useChatStore((s) => s.sendMessage);
31+
const abortRun = useChatStore((s) => s.abortRun);
3132
const clearError = useChatStore((s) => s.clearError);
3233

3334
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -145,6 +146,7 @@ export function Chat() {
145146
{/* Input Area */}
146147
<ChatInput
147148
onSend={sendMessage}
149+
onStop={abortRun}
148150
disabled={!isGatewayRunning}
149151
sending={sending}
150152
/>

src/stores/chat.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,41 @@ interface ChatState {
6363
newSession: () => void;
6464
loadHistory: () => Promise<void>;
6565
sendMessage: (text: string, attachments?: { type: string; mimeType: string; fileName: string; content: string }[]) => Promise<void>;
66+
abortRun: () => Promise<void>;
6667
handleChatEvent: (event: Record<string, unknown>) => void;
6768
toggleThinking: () => void;
6869
refresh: () => Promise<void>;
6970
clearError: () => void;
7071
}
7172

73+
function isToolOnlyMessage(message: RawMessage | undefined): boolean {
74+
if (!message) return false;
75+
if (message.role === 'toolresult') return true;
76+
77+
const content = message.content;
78+
if (!Array.isArray(content)) return false;
79+
80+
let hasTool = false;
81+
let hasText = false;
82+
let hasNonToolContent = false;
83+
84+
for (const block of content as ContentBlock[]) {
85+
if (block.type === 'tool_use' || block.type === 'tool_result') {
86+
hasTool = true;
87+
continue;
88+
}
89+
if (block.type === 'text' && block.text && block.text.trim()) {
90+
hasText = true;
91+
continue;
92+
}
93+
if (block.type === 'image' || block.type === 'thinking') {
94+
hasNonToolContent = true;
95+
}
96+
}
97+
98+
return hasTool && !hasText && !hasNonToolContent;
99+
}
100+
72101
// ── Store ────────────────────────────────────────────────────────
73102

74103
export const useChatStore = create<ChatState>((set, get) => ({
@@ -260,6 +289,23 @@ export const useChatStore = create<ChatState>((set, get) => ({
260289
}
261290
},
262291

292+
// ── Abort active run ──
293+
294+
abortRun: async () => {
295+
const { currentSessionKey } = get();
296+
set({ sending: false, streamingText: '', streamingMessage: null });
297+
298+
try {
299+
await window.electron.ipcRenderer.invoke(
300+
'gateway:rpc',
301+
'chat.abort',
302+
{ sessionKey: currentSessionKey },
303+
);
304+
} catch (err) {
305+
set({ error: String(err) });
306+
}
307+
},
308+
263309
// ── Handle incoming chat events from Gateway ──
264310

265311
handleChatEvent: (event: Record<string, unknown>) => {
@@ -282,20 +328,32 @@ export const useChatStore = create<ChatState>((set, get) => ({
282328
// Message complete - add to history and clear streaming
283329
const finalMsg = event.message as RawMessage | undefined;
284330
if (finalMsg) {
285-
const msgId = finalMsg.id || `run-${runId}`;
331+
const toolOnly = isToolOnlyMessage(finalMsg);
332+
const msgId = finalMsg.id || (toolOnly ? `run-${runId}-tool-${Date.now()}` : `run-${runId}`);
286333
set((s) => {
287334
// Check if message already exists (prevent duplicates)
288335
const alreadyExists = s.messages.some(m => m.id === msgId);
289336
if (alreadyExists) {
290337
// Just clear streaming state, don't add duplicate
291-
return {
338+
return toolOnly ? {
339+
streamingText: '',
340+
streamingMessage: null,
341+
} : {
292342
streamingText: '',
293343
streamingMessage: null,
294344
sending: false,
295345
activeRunId: null,
296346
};
297347
}
298-
return {
348+
return toolOnly ? {
349+
messages: [...s.messages, {
350+
...finalMsg,
351+
role: finalMsg.role || 'assistant',
352+
id: msgId,
353+
}],
354+
streamingText: '',
355+
streamingMessage: null,
356+
} : {
299357
messages: [...s.messages, {
300358
...finalMsg,
301359
role: finalMsg.role || 'assistant',

0 commit comments

Comments
 (0)