From e1fb0e3d190876a516e51b6c7c2917aa4c0ead37 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 27 Dec 2025 18:32:15 +0000 Subject: [PATCH] Story 13: Implement Stop button with backend cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tokio watch channel for cancellation signaling - Implement cancel_chat command - Add cancellation checks in streaming loop and before tool execution - Stop button (■) replaces Send button (↑) during generation - Preserve partial streaming content when cancelled - Clean UX: no error messages on cancellation - Backend properly stops streaming and prevents tool execution Closes Story 13 --- .../stories/13_interrupt_on_typing.md | 94 -- .../stories/15_new_session_cancellation.md | 99 ++ .../stories/archive/13_stop_button.md | 82 + src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/chat.rs | 28 +- src-tauri/src/lib.rs | 3 +- src-tauri/src/llm/ollama.rs | 25 +- src-tauri/src/state.rs | 6 + src/components/Chat.tsx | 1344 +++++++++-------- 10 files changed, 920 insertions(+), 763 deletions(-) delete mode 100644 .living_spec/stories/13_interrupt_on_typing.md create mode 100644 .living_spec/stories/15_new_session_cancellation.md create mode 100644 .living_spec/stories/archive/13_stop_button.md diff --git a/.living_spec/stories/13_interrupt_on_typing.md b/.living_spec/stories/13_interrupt_on_typing.md deleted file mode 100644 index 9ab10aa..0000000 --- a/.living_spec/stories/13_interrupt_on_typing.md +++ /dev/null @@ -1,94 +0,0 @@ -# Story: Stop Button to Cancel Model Response - -## User Story -**As a** User -**I want** a Stop button to appear while the model is generating a response -**So that** I can explicitly cancel long-running or unwanted responses without waiting for completion. - -## Acceptance Criteria -* [ ] A "Stop" button should appear in place of the Send button while the model is generating -* [ ] Clicking the Stop button should immediately cancel the ongoing generation -* [ ] The backend request to Ollama should be cancelled (not just ignored) -* [ ] Any partial response generated before stopping should remain visible in the chat -* [ ] The UI should return to normal state (Send button visible, input enabled) after stopping -* [ ] The input field should remain enabled during generation (user can type while waiting) -* [ ] Optional: Escape key should also trigger stop (keyboard shortcut) -* [ ] The stopped message should remain in history (not be removed) - -## Out of Scope -* Automatic interruption by typing (too aggressive) -* Confirmation dialog before stopping (immediate action is preferred) -* Undo/redo functionality after stopping -* Streaming partial responses (that's Story 18) - -## Implementation Notes - -### Frontend (TypeScript) -* Replace Send button (↑) with Stop button (⬛ or "Stop") when `loading` is true -* On Stop click, call `invoke("cancel_chat")` and set `loading = false` -* Keep input field enabled during generation (no `disabled` attribute) -* Optional: Add Escape key handler to trigger stop when input is focused -* Visual design: Make Stop button clearly distinct from Send button - -### Backend (Rust) -* ✅ Already implemented: `cancel_chat` command with tokio watch channel -* ✅ Already implemented: `tokio::select!` racing Ollama request vs cancellation -* When cancelled, backend returns early with "Chat cancelled by user" error -* Partial messages from completed tool calls remain in history - -### UX Flow -1. User sends message → Send button changes to Stop button -2. Model starts generating → User sees "Thinking..." and Stop button -3. User clicks Stop → Backend cancels Ollama request -4. Partial response (if any) stays visible in chat -5. Stop button changes back to Send button -6. User can now send a new message - -### Standard Pattern (ChatGPT/Claude style) -* Stop button is the standard pattern used by ChatGPT, Claude, and other chat UIs -* No auto-interrupt on typing (too confusing - messages would disappear) -* Explicit user action required (button click or Escape key) -* Partial responses remain visible (not removed from history) - -## Related Functional Specs -* Functional Spec: UI/UX -* Related to Story 18 (Streaming) - Stop button should work with streaming too - -## Technical Details - -### Backend Cancellation (Already Implemented) -```rust -// In SessionState -pub cancel_tx: watch::Sender, -pub cancel_rx: watch::Receiver, - -// In chat command -select! { - result = chat_future => { /* normal completion */ } - _ = cancel_rx.changed() => { - return Err("Chat cancelled by user".to_string()); - } -} -``` - -### Frontend Integration -```tsx - - -const cancelGeneration = () => { - invoke("cancel_chat").catch(console.error); - setLoading(false); -}; -``` - -## Testing Considerations -* Test with long multi-turn generations (tool use) -* Test that partial responses remain visible -* Test that new messages can be sent after stopping -* Test Escape key shortcut (if implemented) -* Test that backend actually cancels (check Ollama logs/CPU) \ No newline at end of file diff --git a/.living_spec/stories/15_new_session_cancellation.md b/.living_spec/stories/15_new_session_cancellation.md new file mode 100644 index 0000000..b6e9112 --- /dev/null +++ b/.living_spec/stories/15_new_session_cancellation.md @@ -0,0 +1,99 @@ +# Story 14: New Session Cancellation + +## User Story +**As a** User +**I want** the backend to stop processing when I start a new session +**So that** tools don't silently execute in the background and streaming doesn't leak into my new session + +## The Problem + +**Current Behavior (THE BUG):** +1. User sends message → Backend starts streaming → About to execute a tool (e.g., `write_file`) +2. User clicks "New Session" and confirms +3. Frontend clears messages and UI state +4. **Backend keeps running** → Tool executes → File gets written → Streaming continues +5. **Streaming tokens appear in the new session** +6. User has no idea these side effects occurred in the background + +**Why This Is Critical:** +- Tool calls have real side effects (file writes, shell commands, searches) +- These happen silently after user thinks they've started fresh +- Streaming from old session leaks into new session +- Can cause confusion, data corruption, or unexpected system state +- User expects "New Session" to mean a clean slate + +## Acceptance Criteria + +- [ ] Clicking "New Session" and confirming cancels any in-flight backend request +- [ ] Tool calls that haven't started yet are NOT executed +- [ ] Streaming from old request does NOT appear in new session +- [ ] Backend stops processing immediately when cancellation is triggered +- [ ] New session starts with completely clean state +- [ ] No silent side effects in background after new session starts + +## Out of Scope +- Stop button during generation (that's Story 13) +- Improving the confirmation dialog (already done in Story 20) +- Rolling back already-executed tools (partial work stays) + +## Implementation Approach + +### Backend +- Uses same `cancel_chat` command as Story 13 +- Same cancellation mechanism (tokio::select!, watch channel) + +### Frontend +- Call `invoke("cancel_chat")` BEFORE clearing UI state in `clearSession()` +- Wait for cancellation to complete before clearing messages +- Ensure old streaming events don't arrive after clear + +## Testing Strategy + +1. **Test Tool Call Prevention:** + - Send message that will use tools (e.g., "search all TypeScript files") + - Click "New Session" while it's thinking + - Confirm in dialog + - Verify tool does NOT execute (check logs/filesystem) + - Verify new session is clean + +2. **Test Streaming Leak Prevention:** + - Send message requesting long response + - While streaming, click "New Session" and confirm + - Verify old streaming stops immediately + - Verify NO tokens from old request appear in new session + - Type new message and verify only new response appears + +3. **Test File Write Prevention:** + - Ask to write a file: "Create test.txt with current timestamp" + - Click "New Session" before tool executes + - Check filesystem: test.txt should NOT exist + - Verify no background file creation happens + +## Success Criteria + +**Before (BROKEN):** +``` +User: "Search files and write results.txt" +Backend: Starts streaming... +User: *clicks New Session, confirms* +Frontend: Clears UI ✓ +Backend: Still running... executes search... writes file... ✗ +Result: File written silently in background ✗ +Old streaming tokens appear in new session ✗ +``` + +**After (FIXED):** +``` +User: "Search files and write results.txt" +Backend: Starts streaming... +User: *clicks New Session, confirms* +Frontend: Calls cancel_chat, waits, then clears UI ✓ +Backend: Receives cancellation, stops immediately ✓ +Backend: Tools NOT executed ✓ +Result: Clean new session, no background activity ✓ +``` + +## Related Stories +- Story 13: Stop Button (shares same backend cancellation mechanism) +- Story 20: New Session confirmation dialog (UX for triggering this) +- Story 18: Streaming Responses (must not leak between sessions) \ No newline at end of file diff --git a/.living_spec/stories/archive/13_stop_button.md b/.living_spec/stories/archive/13_stop_button.md new file mode 100644 index 0000000..b8d6fed --- /dev/null +++ b/.living_spec/stories/archive/13_stop_button.md @@ -0,0 +1,82 @@ +# Story 13: Stop Button + +## User Story +**As a** User +**I want** a Stop button to cancel the model's response while it's generating +**So that** I can immediately stop long-running or unwanted responses without waiting for completion + +## The Problem + +**Current Behavior:** +- User sends message → Model starts generating +- User realizes they don't want the response (wrong question, too long, etc.) +- **No way to stop it** - must wait for completion +- Tool calls will execute even if user wants to cancel + +**Why This Matters:** +- Long responses waste time +- Tool calls have side effects (file writes, searches, shell commands) +- User has no control once generation starts +- Standard UX pattern in ChatGPT, Claude, etc. + +## Acceptance Criteria + +- [ ] Stop button (⬛) appears in place of Send button (↑) while model is generating +- [ ] Clicking Stop immediately cancels the backend request +- [ ] Tool calls that haven't started yet are NOT executed after cancellation +- [ ] Streaming stops immediately +- [ ] Partial response generated before stopping remains visible in chat +- [ ] Stop button becomes Send button again after cancellation +- [ ] User can immediately send a new message after stopping +- [ ] Input field remains enabled during generation + +## Out of Scope +- Escape key shortcut (can add later) +- Confirmation dialog (immediate action is better UX) +- Undo/redo functionality +- New Session flow (that's Story 14) + +## Implementation Approach + +### Backend +- Add `cancel_chat` command callable from frontend +- Use `tokio::select!` to race chat execution vs cancellation signal +- Check cancellation before executing each tool +- Return early when cancelled (not an error - expected behavior) + +### Frontend +- Replace Send button with Stop button when `loading` is true +- On Stop click: call `invoke("cancel_chat")` and set `loading = false` +- Keep input enabled during generation +- Visual: Make Stop button clearly distinct (⬛ or "Stop" text) + +## Testing Strategy + +1. **Test Stop During Streaming:** + - Send message requesting long response + - Click Stop while streaming + - Verify streaming stops immediately + - Verify partial response remains visible + - Verify can send new message + +2. **Test Stop Before Tool Execution:** + - Send message that will use tools + - Click Stop while "thinking" (before tool executes) + - Verify tool does NOT execute (check logs/filesystem) + +3. **Test Stop During Tool Execution:** + - Send message with multiple tool calls + - Click Stop after first tool executes + - Verify remaining tools do NOT execute + +## Success Criteria + +**Before:** +- User sends message → No way to stop → Must wait for completion → Frustrating UX + +**After:** +- User sends message → Stop button appears → User clicks Stop → Generation cancels immediately → Partial response stays → Can send new message + +## Related Stories +- Story 14: New Session Cancellation (same backend mechanism, different trigger) +- Story 18: Streaming Responses (Stop must work with streaming) \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f54ecb6..52dedd8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2084,6 +2084,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-store", + "tokio", "uuid", "walkdir", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6cea5b3..1b947e4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,3 +31,4 @@ uuid = { version = "1.19.0", features = ["v4", "serde"] } chrono = { version = "0.4.42", features = ["serde"] } async-trait = "0.1.89" tauri-plugin-store = "2.4.1" +tokio = { version = "1", features = ["sync"] } diff --git a/src-tauri/src/commands/chat.rs b/src-tauri/src/commands/chat.rs index f268eec..861e4bc 100644 --- a/src-tauri/src/commands/chat.rs +++ b/src-tauri/src/commands/chat.rs @@ -30,6 +30,16 @@ pub async fn chat( config: ProviderConfig, state: State<'_, SessionState>, ) -> Result, String> { + // Reset cancel flag at start of new request + let _ = state.cancel_tx.send(false); + + // Get a clone of the cancellation receiver + let mut cancel_rx = state.cancel_rx.clone(); + + // Mark the receiver as having seen the current (false) value + // This prevents changed() from firing immediately due to stale state + cancel_rx.borrow_and_update(); + // 1. Setup Provider let base_url = config .base_url @@ -79,6 +89,11 @@ pub async fn chat( let mut turn_count = 0; loop { + // Check for cancellation at start of loop + if *cancel_rx.borrow() { + return Err("Chat cancelled by user".to_string()); + } + if turn_count >= MAX_TURNS { return Err("Max conversation turns reached.".to_string()); } @@ -86,7 +101,7 @@ pub async fn chat( // Call LLM with streaming let response = provider - .chat_stream(&app, &config.model, ¤t_history, tools) + .chat_stream(&app, &config.model, ¤t_history, tools, &mut cancel_rx) .await .map_err(|e| format!("LLM Error: {}", e))?; @@ -108,6 +123,11 @@ pub async fn chat( // Execute Tools for call in tool_calls { + // Check for cancellation before executing each tool + if *cancel_rx.borrow() { + return Err("Chat cancelled before tool execution".to_string()); + } + let output = execute_tool(&call, &state).await; let tool_msg = Message { @@ -289,3 +309,9 @@ fn get_tool_definitions() -> Vec { }, ] } + +#[tauri::command] +pub async fn cancel_chat(state: State<'_, SessionState>) -> Result<(), String> { + state.cancel_tx.send(true).map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ccdfd0a..9292af4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,7 +23,8 @@ pub fn run() { commands::search::search_files, commands::shell::exec_shell, commands::chat::chat, - commands::chat::get_ollama_models + commands::chat::get_ollama_models, + commands::chat::cancel_chat ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/llm/ollama.rs b/src-tauri/src/llm/ollama.rs index f000f72..5249145 100644 --- a/src-tauri/src/llm/ollama.rs +++ b/src-tauri/src/llm/ollama.rs @@ -47,6 +47,7 @@ impl OllamaProvider { model: &str, messages: &[Message], tools: &[ToolDefinition], + cancel_rx: &mut tokio::sync::watch::Receiver, ) -> Result { let client = reqwest::Client::new(); let url = format!("{}/api/chat", self.base_url.trim_end_matches('/')); @@ -108,7 +109,29 @@ impl OllamaProvider { let mut accumulated_content = String::new(); let mut final_tool_calls: Option> = None; - while let Some(chunk_result) = stream.next().await { + loop { + // Check for cancellation + if *cancel_rx.borrow() { + return Err("Chat cancelled by user".to_string()); + } + + let chunk_result = tokio::select! { + chunk = stream.next() => { + match chunk { + Some(c) => c, + None => break, + } + } + _ = cancel_rx.changed() => { + // changed() fires on any change, check if it's actually true + if *cancel_rx.borrow() { + return Err("Chat cancelled by user".to_string()); + } else { + continue; + } + } + }; + let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?; buffer.push_str(&String::from_utf8_lossy(&chunk)); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 25cf98b..6468d29 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,14 +1,20 @@ use std::path::PathBuf; use std::sync::Mutex; +use tokio::sync::watch; pub struct SessionState { pub project_root: Mutex>, + pub cancel_tx: watch::Sender, + pub cancel_rx: watch::Receiver, } impl Default for SessionState { fn default() -> Self { + let (cancel_tx, cancel_rx) = watch::channel(false); Self { project_root: Mutex::new(None), + cancel_tx, + cancel_rx, } } } diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index bdd436f..9545780 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -8,706 +8,718 @@ import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import type { Message, ProviderConfig } from "../types"; interface ChatProps { - projectPath: string; - onCloseProject: () => void; + projectPath: string; + onCloseProject: () => void; } export function Chat({ projectPath, onCloseProject }: ChatProps) { - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [loading, setLoading] = useState(false); - const [model, setModel] = useState("llama3.1"); // Default local model - const [enableTools, setEnableTools] = useState(true); - const [availableModels, setAvailableModels] = useState([]); - const [streamingContent, setStreamingContent] = useState(""); - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - const sessionIdRef = useRef(Date.now()); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [model, setModel] = useState("llama3.1"); // Default local model + const [enableTools, setEnableTools] = useState(true); + const [availableModels, setAvailableModels] = useState([]); + const [streamingContent, setStreamingContent] = useState(""); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); - // Token estimation and context window tracking - const estimateTokens = (text: string): number => { - return Math.ceil(text.length / 4); - }; + // Token estimation and context window tracking + const estimateTokens = (text: string): number => { + return Math.ceil(text.length / 4); + }; - const getContextWindowSize = (modelName: string): number => { - if (modelName.includes("llama3")) return 8192; - if (modelName.includes("qwen2.5")) return 32768; - if (modelName.includes("deepseek")) return 16384; - return 8192; // Default - }; + const getContextWindowSize = (modelName: string): number => { + if (modelName.includes("llama3")) return 8192; + if (modelName.includes("qwen2.5")) return 32768; + if (modelName.includes("deepseek")) return 16384; + return 8192; // Default + }; - const calculateContextUsage = (): { - used: number; - total: number; - percentage: number; - } => { - let totalTokens = 0; + const calculateContextUsage = (): { + used: number; + total: number; + percentage: number; + } => { + let totalTokens = 0; - // System prompts (approximate) - totalTokens += 200; + // System prompts (approximate) + totalTokens += 200; - // All messages - for (const msg of messages) { - totalTokens += estimateTokens(msg.content); - if (msg.tool_calls) { - totalTokens += estimateTokens(JSON.stringify(msg.tool_calls)); - } - } + // All messages + for (const msg of messages) { + totalTokens += estimateTokens(msg.content); + if (msg.tool_calls) { + totalTokens += estimateTokens(JSON.stringify(msg.tool_calls)); + } + } - // Streaming content - if (streamingContent) { - totalTokens += estimateTokens(streamingContent); - } + // Streaming content + if (streamingContent) { + totalTokens += estimateTokens(streamingContent); + } - const contextWindow = getContextWindowSize(model); - const percentage = Math.round((totalTokens / contextWindow) * 100); + const contextWindow = getContextWindowSize(model); + const percentage = Math.round((totalTokens / contextWindow) * 100); - return { - used: totalTokens, - total: contextWindow, - percentage, - }; - }; + return { + used: totalTokens, + total: contextWindow, + percentage, + }; + }; - const contextUsage = calculateContextUsage(); + const contextUsage = calculateContextUsage(); - const getContextEmoji = (percentage: number): string => { - if (percentage >= 90) return "🔴"; - if (percentage >= 75) return "🟡"; - return "🟢"; - }; + const getContextEmoji = (percentage: number): string => { + if (percentage >= 90) return "🔴"; + if (percentage >= 75) return "🟡"; + return "🟢"; + }; - useEffect(() => { - invoke("get_ollama_models") - .then(async (models) => { - if (models.length > 0) { - setAvailableModels(models); + useEffect(() => { + invoke("get_ollama_models") + .then(async (models) => { + if (models.length > 0) { + setAvailableModels(models); - // Check backend store for saved model - try { - const savedModel = await invoke( - "get_model_preference", - ); - if (savedModel && models.includes(savedModel)) { - setModel(savedModel); - } else if (!models.includes(model)) { - setModel(models[0]); - } - } catch (e) { - console.error(e); - } - } - }) - .catch((err) => console.error(err)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [model]); + // Check backend store for saved model + try { + const savedModel = await invoke( + "get_model_preference", + ); + if (savedModel && models.includes(savedModel)) { + setModel(savedModel); + } else if (!models.includes(model)) { + setModel(models[0]); + } + } catch (e) { + console.error(e); + } + } + }) + .catch((err) => console.error(err)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [model]); - useEffect(() => { - const currentSessionId = sessionIdRef.current; + useEffect(() => { + const unlistenUpdatePromise = listen("chat:update", (event) => { + setMessages(event.payload); + setStreamingContent(""); // Clear streaming content when final update arrives + }); - const unlistenUpdatePromise = listen("chat:update", (event) => { - // Only update if this is still the current session - if (sessionIdRef.current === currentSessionId) { - setMessages(event.payload); - setStreamingContent(""); // Clear streaming content when final update arrives - } - }); + const unlistenTokenPromise = listen("chat:token", (event) => { + setStreamingContent((prev) => prev + event.payload); + }); - const unlistenTokenPromise = listen("chat:token", (event) => { - // Only append tokens if this is still the current session - if (sessionIdRef.current === currentSessionId) { - setStreamingContent((prev) => prev + event.payload); - } - }); + return () => { + unlistenUpdatePromise.then((unlisten) => unlisten()); + unlistenTokenPromise.then((unlisten) => unlisten()); + }; + }, []); - return () => { - unlistenUpdatePromise.then((unlisten) => unlisten()); - unlistenTokenPromise.then((unlisten) => unlisten()); - }; - }, []); + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }; + // biome-ignore lint/correctness/useExhaustiveDependencies: We intentionally trigger on messages/streamingContent changes + useEffect(scrollToBottom, [messages, streamingContent]); - // biome-ignore lint/correctness/useExhaustiveDependencies: We intentionally trigger on messages/streamingContent changes - useEffect(scrollToBottom, [messages, streamingContent]); + useEffect(() => { + inputRef.current?.focus(); + }, []); - useEffect(() => { - inputRef.current?.focus(); - }, []); + const cancelGeneration = async () => { + try { + await invoke("cancel_chat"); - const sendMessage = async () => { - if (!input.trim() || loading) return; + // Preserve any partial streaming content as a message + if (streamingContent) { + setMessages((prev) => [ + ...prev, + { role: "assistant", content: streamingContent }, + ]); + setStreamingContent(""); + } - const userMsg: Message = { role: "user", content: input }; - const newHistory = [...messages, userMsg]; + setLoading(false); + } catch (e) { + console.error("Failed to cancel chat:", e); + } + }; - setMessages(newHistory); - setInput(""); - setLoading(true); - setStreamingContent(""); // Clear any previous streaming content + const sendMessage = async () => { + if (!input.trim() || loading) return; - try { - const config: ProviderConfig = { - provider: "ollama", - model: model, - base_url: "http://localhost:11434", - enable_tools: enableTools, - }; + const userMsg: Message = { role: "user", content: input }; + const newHistory = [...messages, userMsg]; - // Invoke backend chat command - // We rely on 'chat:update' events to update the state in real-time - await invoke("chat", { - messages: newHistory, - config: config, - }); - } catch (e) { - console.error(e); - setMessages((prev) => [ - ...prev, - { role: "assistant", content: `**Error:** ${e}` }, - ]); - } finally { - setLoading(false); - } - }; + setMessages(newHistory); + setInput(""); + setLoading(true); + setStreamingContent(""); // Clear any previous streaming content - const clearSession = async () => { - const confirmed = await ask( - "Are you sure? This will clear all messages and reset the conversation context.", - { - title: "New Session", - kind: "warning", - }, - ); + try { + const config: ProviderConfig = { + provider: "ollama", + model: model, + base_url: "http://localhost:11434", + enable_tools: enableTools, + }; - if (confirmed) { - // Generate new session ID to ignore old streaming events - sessionIdRef.current = Date.now(); - setMessages([]); - setStreamingContent(""); - setLoading(false); - // TODO: Add backend call to clear context when implemented - // invoke("clear_session").catch(console.error); - } - }; + // Invoke backend chat command + // We rely on 'chat:update' events to update the state in real-time + await invoke("chat", { + messages: newHistory, + config: config, + }); + } catch (e) { + console.error(e); + // Don't show error message if user cancelled + const errorMessage = String(e); + if (!errorMessage.includes("Chat cancelled by user")) { + setMessages((prev) => [ + ...prev, + { role: "assistant", content: `**Error:** ${e}` }, + ]); + } + } finally { + setLoading(false); + } + }; - return ( -
- {/* Sticky Header */} -
- {/* Project Info */} -
-
- {projectPath} -
- -
+ const clearSession = async () => { + const confirmed = await ask( + "Are you sure? This will clear all messages and reset the conversation context.", + { + title: "New Session", + kind: "warning", + }, + ); - {/* Model Controls */} -
- {/* Context Usage Indicator */} -
- {getContextEmoji(contextUsage.percentage)} {contextUsage.percentage} - % -
+ if (confirmed) { + setMessages([]); + setStreamingContent(""); + setLoading(false); + // TODO: Add backend call to cancel in-flight requests and clear context + // invoke("cancel_chat").catch(console.error); + } + }; - - {availableModels.length > 0 ? ( - - ) : ( - { - const newModel = e.target.value; - setModel(newModel); - invoke("set_model_preference", { model: newModel }).catch( - console.error, - ); - }} - placeholder="Model" - style={{ - padding: "6px 12px", - borderRadius: "99px", - border: "none", - fontSize: "0.9em", - background: "#2f2f2f", - color: "#ececec", - outline: "none", - }} - /> - )} - -
-
+ return ( +
+ {/* Sticky Header */} +
+ {/* Project Info */} +
+
+ {projectPath} +
+ +
- {/* Messages Area */} -
-
- {messages.map((msg, idx) => ( -
-
- {msg.role === "user" ? ( - msg.content - ) : msg.role === "tool" ? ( -
- - - - Tool Output - {msg.tool_call_id && ` (${msg.tool_call_id})`} - - -
-                      {msg.content}
-                    
-
- ) : ( -
- { - const match = /language-(\w+)/.exec(className || ""); - const isInline = !className; - return !isInline && match ? ( - - {String(children).replace(/\n$/, "")} - - ) : ( - - {children} - - ); - }, - }} - > - {msg.content} - -
- )} + {/* Model Controls */} +
+ {/* Context Usage Indicator */} +
+ {getContextEmoji(contextUsage.percentage)} {contextUsage.percentage} + % +
- {/* Show Tool Calls if present */} - {msg.tool_calls && ( -
- {msg.tool_calls.map((tc, i) => { - // Parse arguments to extract key info - let argsSummary = ""; - try { - const args = JSON.parse(tc.function.arguments); - const firstKey = Object.keys(args)[0]; - if (firstKey && args[firstKey]) { - argsSummary = String(args[firstKey]); - // Truncate if too long - if (argsSummary.length > 50) { - argsSummary = `${argsSummary.substring(0, 47)}...`; - } - } - } catch (_e) { - // If parsing fails, just show empty - } + + {availableModels.length > 0 ? ( + + ) : ( + { + const newModel = e.target.value; + setModel(newModel); + invoke("set_model_preference", { model: newModel }).catch( + console.error, + ); + }} + placeholder="Model" + style={{ + padding: "6px 12px", + borderRadius: "99px", + border: "none", + fontSize: "0.9em", + background: "#2f2f2f", + color: "#ececec", + outline: "none", + }} + /> + )} + +
+
- return ( -
- - - {tc.function.name} - {argsSummary && `(${argsSummary})`} - -
- ); - })} -
- )} -
-
- ))} - {loading && streamingContent && ( -
-
- { - const match = /language-(\w+)/.exec(className || ""); - const isInline = !className; - return !isInline && match ? ( - - {String(children).replace(/\n$/, "")} - - ) : ( - - {children} - - ); - }, - }} - > - {streamingContent} - -
-
- )} - {loading && !streamingContent && ( -
- Thinking... -
- )} -
-
-
+ {/* Messages Area */} +
+
+ {messages.map((msg, idx) => ( +
+
+ {msg.role === "user" ? ( + msg.content + ) : msg.role === "tool" ? ( +
+ + + + Tool Output + {msg.tool_call_id && ` (${msg.tool_call_id})`} + + +
+											{msg.content}
+										
+
+ ) : ( +
+ { + const match = /language-(\w+)/.exec(className || ""); + const isInline = !className; + return !isInline && match ? ( + + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); + }, + }} + > + {msg.content} + +
+ )} - {/* Input Area */} -
-
- setInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && sendMessage()} - placeholder="Send a message..." - style={{ - width: "100%", - padding: "14px 20px", - paddingRight: "50px", // space for button - borderRadius: "24px", - border: "1px solid #333", - outline: "none", - fontSize: "1rem", - fontWeight: "500", - background: "#2f2f2f", - color: "#ececec", - boxShadow: "0 2px 6px rgba(0,0,0,0.02)", - }} - /> - -
-
-
- ); + {/* Show Tool Calls if present */} + {msg.tool_calls && ( +
+ {msg.tool_calls.map((tc, i) => { + // Parse arguments to extract key info + let argsSummary = ""; + try { + const args = JSON.parse(tc.function.arguments); + const firstKey = Object.keys(args)[0]; + if (firstKey && args[firstKey]) { + argsSummary = String(args[firstKey]); + // Truncate if too long + if (argsSummary.length > 50) { + argsSummary = `${argsSummary.substring(0, 47)}...`; + } + } + } catch (_e) { + // If parsing fails, just show empty + } + + return ( +
+ + + {tc.function.name} + {argsSummary && `(${argsSummary})`} + +
+ ); + })} +
+ )} +
+
+ ))} + {loading && streamingContent && ( +
+
+ { + const match = /language-(\w+)/.exec(className || ""); + const isInline = !className; + return !isInline && match ? ( + + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); + }, + }} + > + {streamingContent} + +
+
+ )} + {loading && !streamingContent && ( +
+ Thinking... +
+ )} +
+
+
+ + {/* Input Area */} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && sendMessage()} + placeholder="Send a message..." + style={{ + width: "100%", + padding: "14px 20px", + paddingRight: "50px", // space for button + borderRadius: "24px", + border: "1px solid #333", + outline: "none", + fontSize: "1rem", + fontWeight: "500", + background: "#2f2f2f", + color: "#ececec", + boxShadow: "0 2px 6px rgba(0,0,0,0.02)", + }} + /> + +
+
+
+ ); }