Chat Stream & Tool Usage Flow
A Mermaid sequence diagram mirroring the flow documented in docs/chat-flow.html, with source references verified against the actual implementation.
Legend
| Color | Event type |
|---|---|
| π΅ | Setup / IPC call |
| π’ | Streaming chunk |
| π | Tool call |
| π΄ | Tool result |
| π£ | Browser paint |
| π‘ | useEffect (post-paint) |
| βͺ | Finalization |
| β« | Synchronous batch (critical) |
Main Sequence Diagram
sequenceDiagram
autonumber
participant U as User
participant R as "React / useChat.ts"
participant B as "Browser / DOM"
participant IPC as "Tauri Channel"
participant RS as "Rust / Tokio"
participant OA as "Ollama API"
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
%% PHASE 1 β MESSAGE DISPATCH
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
U->>R: sendMessage()
activate R
R->>R: append user msg + assistant placeholder
R->>R: setStreaming(true) Β· clear thinking
R->>R: buildApiMessages()<br/>Ollama format: tool_calls + tool_name
Note right of R: src/hooks/useChat.ts:23β75,203
R->>IPC: invoke("generate_completion_stream", β¦)
activate IPC
IPC->>RS: generate_completion_stream
activate RS
RS->>RS: CancellationToken Β· register request_id
RS->>RS: tokio::spawn(detached task)
Note right of RS: src-tauri/src/commands/ai.rs:461β550
RS-->>IPC: return request_id
deactivate RS
IPC-->>R: request_id
deactivate IPC
R->>R: activeRequestId.current = id
deactivate R
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
%% PHASE 2 β AGENT LOOP SETUP
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
RS->>RS: run_agent_loop()
activate RS
RS->>RS: build_tools() β [write_file, read_file, bash]
RS->>RS: stream_turn() β iteration 0
Note right of RS: src-tauri/src/agent/agent_loop.rs:118β124
RS->>OA: POST /api/chat<br/>stream: true Β· tools Β· think?
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
%% PHASE 3 β STREAMING CHUNKS
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
loop Per NDJSON chunk
OA-->>RS: { message: { content, thinking }, done: false }
RS->>IPC: send(Chunk { text, thinking })
IPC->>R: onmessage(Chunk)
activate R
R->>R: rAF batch β setStreamingContent + setStreamingThinking
Note right of R: src/hooks/useChat.ts:288β307
R->>B: render <Reasoning> + content
Note right of B: π£ PAINT β thinking / text visible
deactivate R
end
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
%% PHASE 4 β TOOL CALL DECISION
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Note over B: isEmpty = isStreaming && content==="" && !streamingThinking<br/>If both empty β Loader variant="typing"
Note right of B: src/components/chat/MessageList.tsx:135,175β176
OA-->>RS: done=true Β· tool_calls=[write_file]
RS->>RS: assistant_msg.tool_calls = tool_calls
RS->>RS: push assistant_msg to history
Note right of RS: agent_loop.rs:76β81
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
%% PHASE 5 β TOOL EXECUTION
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
RS->>IPC: send(ToolCall { tool, args })
RS->>RS: execute_tool("write_file")
Note right of RS: β Ignores model path; uses caller output_path<br/>src-tauri/src/agent/executor.rs:33β89
RS->>RS: tokio::fs::write(project_dir, content)
RS->>IPC: send(ToolResult { tool, success, output, path, content })
Note right of RS: agent_loop.rs:170β189
RS->>RS: wrote_file = true Β· break
RS->>RS: history.push(tool result)
RS->>IPC: send(Done)
deactivate RS
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
%% PHASE 6 β SYNCHRONOUS BATCH (critical)
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Note over IPC: β Tauri Channel while-loop<br/>core.js:99β105<br/>processes queued events<br/>synchronously in ONE JS task
activate R
IPC->>R: onmessage(ToolCall)
R->>R: attachToolCall(pending: true)
Note right of R: src/stores/chatStore.ts:71
R->>R: Zustand schedules render
IPC->>R: onmessage(ToolResult)
R->>R: toolWritten = true Β· cancel rAF
R->>R: onOutput(stripFences(content))
R->>R: pendingToolResultsRef.push({...})
R->>R: setToolResultTick(n+1)
Note right of R: src/hooks/useChat.ts:310β321
IPC->>R: onmessage(Done)
R->>R: finalize()
R->>R: flush queue Β· build finalMessage
R->>R: setMessages Β· setStreaming(false)
R->>R: writeFile(chat.json)
Note right of R: src/hooks/useChat.ts:259β284,322β323
deactivate R
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
%% PHASE 7 β REACT RENDER: SPINNER FRAME
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
R->>B: React flushes batched updates
activate B
R->>B: render Tool card Β· state="input-streaming"
Note right of B: π£ PAINT β spinner visible<br/>Loader2 + "Processing" badge<br/>src/components/ui/tool.tsx:46β71
deactivate B
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
%% PHASE 8 β POST-PAINT useEffect β MARK COMPLETE
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
R->>R: useEffect([toolResultTick]) fires
Note right of R: π‘ Post-paint guarantee<br/>react.dev/reference/react/useEffect<br/>Spinner guaranteed β₯1 frame before completion
R->>R: drain queue β updateLastToolResult(pending: false)
R->>R: patchLastToolCallPath(path)
Note right of R: src/hooks/useChat.ts:190β201
R->>B: render Tool card Β· state="output-available"
Note right of B: π£ PAINT β completed state<br/>CheckCircle + "Completed" badge
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
%% MULTI-TURN CONTINUATION
%% ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Note over RS: If wrote_file == false:<br/>rebuild request(vec![]) Β· stream_turn() again<br/>MAX_ITERATIONS = 10
Note right of RS: agent_loop.rs:140β212
Tool Card State Machine
stateDiagram-v2
[*] --> idle: Initial state
idle --> input_streaming: onmessage(ToolCall)<br/>attachToolCall(pending:true)
input_streaming --> output_available: useEffect drains queue<br/>updateLastToolResult(pending:false)
input_streaming --> output_error: Tool execution failed<br/>success=false
output_available --> [*]: Render complete
output_error --> [*]: Render complete
note right of input_streaming
PAINT guarantee: useEffect fires AFTER
browser has painted spinner for β₯1 frame.
Without this deferred queue, React would batch
ToolCall+ToolResult+Done and the spinner would
never become visible.
end note
Verified Implementation Details
Tauri Channel Batching (core.js:99β105)
The @tauri-apps/api/core.js Channel class queues out-of-order messages and drains them synchronously:
while (nextIndex in pendingMessages) {
const message = pendingMessages[nextIndex];
onmessage.call(this, message);
delete pendingMessages[nextIndex];
nextIndex++;
}
If Rust sends ToolCall β ToolResult β Done in rapid succession, all three onmessage handlers execute in a single uninterruptible JS task. No React render can paint between them. The pendingToolResultsRef + useEffect mechanism breaks this synchronous chain by deferring the visual store update until after the browser has painted.
Source verification: Line 99 of node_modules/@tauri-apps/api/core.js (transpiled output, matches Tauri v2 IPC source).
Tokio CancellationToken + tokio::select!
Cancellation is cooperative. The CancellationToken is cloned: one copy stored in AppState.cancellation_tokens (HashMap), one moved into the spawned task. When the frontend calls stop_generation_stream(request_id), the original tokenβs .cancel() method is called, which resolves .cancelled() in the spawned task, causing tokio::select! to drop the HTTP stream and close the TCP connection.
Per tokio-util docs: CancellationToken supports clone + cancel for cooperative cancellation.
Per Ollama API docs: There is no /api/abort endpoint. Dropping the connection is the standard cancellation pattern.
ollama-rs History Helper Limitation
send_chat_messages_with_history_stream accumulates text content and pushes ChatMessage::assistant(content) β it does not preserve tool_calls in the assistant history entry. Without the manual fix at agent_loop.rs:76β81, multi-turn tool conversations break because Ollama receives a tool role message with no prior tool_calls in the assistant message.
Write-File Path Hard-Coding
execute_write_file intentionally ignores the model-provided filename and uses the callerβs output_path parameter (executor.rs:51). This prevents path-traversal attacks (the model hallucinates paths like "../../../etc/passwd"). A .. guard is also present. Parent directories are auto-created via tokio::fs::create_dir_all.
Two Ollama Paths
generate_ollama_completion_stream branches at ai.rs:209:
output_pathpresent β agent loop path viaollama-rs(tool calling, manages its own history)output_pathnull β direct HTTP path (reqwest+ raw JSON). Used when model capabilities donβt include tools. The direct path builds JSON manually viamessages_to_ollama_jsonto support thetool_namefield, which ollama-rsChatMessagelacks.
File Index
| File | Role |
|---|---|
src/hooks/useChat.ts |
Main React hook: event handlers, deferred queue, finalize |
src/stores/chatStore.ts |
Zustand store: attachToolCall, updateLastToolResult, patchLastToolCallPath |
src/lib/ipc.ts |
generateCompletionStream wrapper, CompletionEvent type |
src-tauri/src/commands/ai.rs |
CompletionEvent enum, command registration, Ollama provider branching |
src-tauri/src/agent/agent_loop.rs |
run_agent_loop, stream_turn, manual history fix |
src-tauri/src/agent/executor.rs |
execute_tool, execute_write_file, execute_read_file, execute_bash |
src/types/chat.ts |
ChatMessage, ToolCallRecord types |
src/components/chat/MessageList.tsx |
Message rendering: reasoning blocks, tool cards, loader states |
src/components/ui/tool.tsx |
Generic tool UI: input-streaming, output-available, output-error states |
node_modules/@tauri-apps/api/core.js |
Tauri v2 Channel IPC: transformCallback, pendingMessages queue |