Story 13: Implement Stop button with backend cancellation

- 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
This commit is contained in:
Dave
2025-12-27 18:32:15 +00:00
parent 846967ee99
commit e1fb0e3d19
10 changed files with 920 additions and 763 deletions

View File

@@ -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<bool>,
pub cancel_rx: watch::Receiver<bool>,
// In chat command
select! {
result = chat_future => { /* normal completion */ }
_ = cancel_rx.changed() => {
return Err("Chat cancelled by user".to_string());
}
}
```
### Frontend Integration
```tsx
<button
onClick={loading ? cancelGeneration : sendMessage}
disabled={!input.trim() && !loading}
>
{loading ? "⬛ Stop" : "↑"}
</button>
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)

View File

@@ -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)

View File

@@ -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)

1
src-tauri/Cargo.lock generated
View File

@@ -2084,6 +2084,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-store",
"tokio",
"uuid",
"walkdir",
]

View File

@@ -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"] }

View File

@@ -30,6 +30,16 @@ pub async fn chat(
config: ProviderConfig,
state: State<'_, SessionState>,
) -> Result<Vec<Message>, 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, &current_history, tools)
.chat_stream(&app, &config.model, &current_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<ToolDefinition> {
},
]
}
#[tauri::command]
pub async fn cancel_chat(state: State<'_, SessionState>) -> Result<(), String> {
state.cancel_tx.send(true).map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -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");

View File

@@ -47,6 +47,7 @@ impl OllamaProvider {
model: &str,
messages: &[Message],
tools: &[ToolDefinition],
cancel_rx: &mut tokio::sync::watch::Receiver<bool>,
) -> Result<CompletionResponse, String> {
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<Vec<ToolCall>> = 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));

View File

@@ -1,14 +1,20 @@
use std::path::PathBuf;
use std::sync::Mutex;
use tokio::sync::watch;
pub struct SessionState {
pub project_root: Mutex<Option<PathBuf>>,
pub cancel_tx: watch::Sender<bool>,
pub cancel_rx: watch::Receiver<bool>,
}
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,
}
}
}

View File

@@ -22,7 +22,6 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const [streamingContent, setStreamingContent] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const sessionIdRef = useRef(Date.now());
// Token estimation and context window tracking
const estimateTokens = (text: string): number => {
@@ -103,21 +102,13 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
}, [model]);
useEffect(() => {
const currentSessionId = sessionIdRef.current;
const unlistenUpdatePromise = listen<Message[]>("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<string>("chat:token", (event) => {
// Only append tokens if this is still the current session
if (sessionIdRef.current === currentSessionId) {
setStreamingContent((prev) => prev + event.payload);
}
});
return () => {
@@ -137,6 +128,25 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
inputRef.current?.focus();
}, []);
const cancelGeneration = async () => {
try {
await invoke("cancel_chat");
// Preserve any partial streaming content as a message
if (streamingContent) {
setMessages((prev) => [
...prev,
{ role: "assistant", content: streamingContent },
]);
setStreamingContent("");
}
setLoading(false);
} catch (e) {
console.error("Failed to cancel chat:", e);
}
};
const sendMessage = async () => {
if (!input.trim() || loading) return;
@@ -164,10 +174,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
});
} 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);
}
@@ -183,13 +197,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
);
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);
// TODO: Add backend call to cancel in-flight requests and clear context
// invoke("cancel_chat").catch(console.error);
}
};
@@ -684,8 +696,8 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
/>
<button
type="button"
onClick={sendMessage}
disabled={loading}
onClick={loading ? cancelGeneration : sendMessage}
disabled={!loading && !input.trim()}
style={{
position: "absolute",
right: "8px",
@@ -700,11 +712,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: loading ? "not-allowed" : "pointer",
opacity: loading ? 0.5 : 1,
cursor: "pointer",
opacity: !loading && !input.trim() ? 0.5 : 1,
}}
>
{loading ? "■" : "↑"}
</button>
</div>
</div>