Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions src/pages/Chat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface ChatAttachment {

interface ChatInputProps {
onSend: (text: string, attachments?: ChatAttachment[]) => void;
onStop?: () => void;
disabled?: boolean;
sending?: boolean;
}
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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;
Expand All @@ -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();
}
Expand Down Expand Up @@ -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}
Expand All @@ -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" />
Expand Down
2 changes: 2 additions & 0 deletions src/pages/Chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -145,6 +146,7 @@ export function Chat() {
{/* Input Area */}
<ChatInput
onSend={sendMessage}
onStop={abortRun}
disabled={!isGatewayRunning}
sending={sending}
/>
Expand Down
64 changes: 61 additions & 3 deletions src/stores/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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>) => {
Expand All @@ -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',
Expand Down
Loading