feat: add early tool call feedback for improved UI responsiveness

Emit tool call start events immediately when tools begin execution,
allowing UI to display contextual "thinking" messages (e.g., "Generating
note contents..." for WriteVaultFile) before tool completion. Add
Spinner to ThoughtIndicator component for visual feedback during tool
operations.
This commit is contained in:
Andrew Beal 2026-02-16 17:40:14 +00:00
parent 89fa8b809c
commit 2bc12691d3
12 changed files with 78 additions and 8 deletions

View file

@ -115,6 +115,13 @@ export class Claude extends BaseAIClass {
this.accumulatedFunctionName = toolBlock.name || null;
this.accumulatedFunctionArgs = "";
this.accumulatedFunctionId = toolBlock.id || null;
// Return immediately to signal tool call has started
return {
content: "",
isComplete: false,
toolCallStarted: toolBlock.name
};
}
}

View file

@ -7,7 +7,7 @@ import { AIProvider, AIProviderURL } from "Enums/ApiProvider";
import { AIToolCall } from "AIClasses/AIToolCall";
import { fromString as aiToolFromString } from "Enums/AITool";
import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition";
import type { ResponseEvent, ResponseOutputTextDelta, ResponseOutputItemDone, ResponseErrorEvent, ResponseFailedEvent, OpenAIToolTool, ResponsesAPIInput } from "./OpenAITypes";
import type { ResponseEvent, ResponseOutputTextDelta, ResponseOutputItemAdded, ResponseOutputItemDone, ResponseErrorEvent, ResponseFailedEvent, OpenAIToolTool, ResponsesAPIInput } from "./OpenAITypes";
import { Exception } from "Helpers/Exception";
import { ApiError, ApiErrorType } from "Types/ApiError";
import { MimeType, toMimeType } from "Enums/MimeType";
@ -138,6 +138,21 @@ export class OpenAI extends BaseAIClass {
break;
}
case "response.output_item.added": {
// Function call starting - get name immediately for early UI feedback
const itemAddedEvent = event as ResponseOutputItemAdded;
// Check if this is a function call and return tool name immediately
if (itemAddedEvent.item.type === "function_call" && itemAddedEvent.item.name) {
return {
content: "",
isComplete: false,
toolCallStarted: itemAddedEvent.item.name
};
}
break;
}
case "response.output_item.done": {
// Complete output item received - this includes function calls with name
const itemDoneEvent = event as ResponseOutputItemDone;
@ -167,7 +182,6 @@ export class OpenAI extends BaseAIClass {
case "response.in_progress":
case "response.content_part.added":
case "response.content_part.done":
case "response.output_item.added":
case "response.output_text.done":
case "response.web_search_call.in_progress":
case "response.web_search_call.searching":

View file

@ -21,6 +21,19 @@ export interface ResponseToolCallArgumentsDone extends ResponseEvent {
sequence_number: number;
}
export interface ResponseOutputItemAdded extends ResponseEvent {
type: "response.output_item.added";
response_id: string;
output_index: number;
item: {
type: string;
id?: string;
call_id?: string;
name?: string;
arguments?: string;
};
}
export interface ResponseOutputItemDone extends ResponseEvent {
type: "response.output_item.done";
item_id: string;

View file

@ -289,7 +289,7 @@
{/if}
{/each}
<ThoughtIndicator thought={currentThought} bind:thoughtIndicatorElement={thoughtIndicatorElement}/>
<ThoughtIndicator thought={currentThought} {editModeActive} bind:thoughtIndicatorElement={thoughtIndicatorElement}/>
{#if isSubmitting}
<StreamingIndicator editModeActive={editModeActive} bind:streamingIndicatorElement={streamingIndicatorElement}/>
{/if}

View file

@ -114,7 +114,7 @@
{#if busyPlanning}
<div id="chat-planning-in-progress" transition:slide>
<Spinner {editModeActive}/>
<Spinner {editModeActive} alternateBackground={true}/>
<span id="chat-planning-in-progress-text">{$executionPlanState.plan?.isReplan ? "Replanning in progress..." : "Planning in progress..."}</span>
</div>
{/if}

View file

@ -20,6 +20,7 @@
import type { ExecutionPlanStore } from "Stores/ExecutionPlanStore";
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
import { HTMLService } from "Services/HTMLService";
import { AITool, fromString } from "Enums/AITool";
const plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
const executionPlanStore: ExecutionPlanStore = Resolve<ExecutionPlanStore>(Services.ExecutionPlanStore);
@ -121,6 +122,19 @@
currentThought = thought;
}
},
onToolCallStarted: (toolName: string) => {
const tool = fromString(toolName);
switch(tool) {
case AITool.WriteVaultFile:
case AITool.PatchVaultFile:
currentThought = Copy.AIThoughtGeneratingNote;
break;
case AITool.AskUserQuestionPlanning:
case AITool.AskUserQuestionExecution:
currentThought = Copy.AIThoughtPreparingQuery;
break;
}
},
onPlanningStarted: () => {
busyPlanning = true;
},

View file

@ -2,6 +2,7 @@
export let width: string = "18px";
export let height: string = "18px";
export let editModeActive: boolean = false;
export let alternateBackground: boolean = false;
export let spinnerElement: HTMLDivElement | undefined = undefined;
</script>
@ -11,7 +12,7 @@
style="width: {width}; height: {height};"
bind:this={spinnerElement}
>
<div class="circle-core"></div>
<div class="circle-core" class:alternate-background={alternateBackground}></div>
</div>
<style>
@ -47,7 +48,11 @@
.circle-core {
width: 100%;
height: 100%;
background-color: var(--background-secondary-alt);
background-color: var(--background-secondary);
border-radius: 50%;
}
.circle-core.alternate-background {
background-color: var(--background-secondary-alt);
}
</style>

View file

@ -1,14 +1,19 @@
<script lang="ts">
import { fade } from "svelte/transition";
import Spinner from "./Spinner.svelte";
export let thought: string | null = null;
export let thoughtIndicatorElement: HTMLElement | undefined = undefined;
export let editModeActive : boolean = false;
$: isVisible = thought !== null && thought.trim().length > 0;
</script>
{#if isVisible}
<div class="ai-thought-container" in:fade={{ duration: 200 }} out:fade={{ duration: 200 }} bind:this={thoughtIndicatorElement}>
<div class="ai-thought-bubble">
<span>{thought}</span>
<span><Spinner {editModeActive}/> {thought}</span>
</div>
</div>
{/if}
@ -33,12 +38,15 @@
z-index: 1;
background: var(--background-primary-alt);
border-radius: 10px;
padding: 0.75rem 1rem;
padding: 0.55rem 0.7rem;
font-size: var(--font-smallest);
color: var(--text-muted);
font-style: italic;
word-wrap: break-word;
width: 100%;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.ai-thought-bubble::after {

View file

@ -73,6 +73,8 @@ export enum Copy {
TooltipLearnMoreFileMonitoring = "Learn more in Plugin Guide",
AIThoughtMessage = "Thinking...",
AIThoughtGeneratingNote = "Generating note contents...",
AIThoughtPreparingQuery = "Preparing user query...",
// Rate Limit Countdown
RateLimitCountdown = "Rate limit exceeded retrying in {seconds}...",

View file

@ -138,6 +138,11 @@ export abstract class BaseAgent {
break;
}
if (chunk.toolCallStarted) {
this.debugService?.log("ToolCallStarted", `Tool call started: ${chunk.toolCallStarted}`);
callbacks.onToolCallStarted(chunk.toolCallStarted);
}
if (chunk.toolCall) {
this.debugService?.log("ToolCall", `Function call captured: ${chunk.toolCall.name}`);
capturedToolCall = chunk.toolCall;

View file

@ -21,6 +21,7 @@ export interface IChatServiceCallbacks {
onSubmit: () => void;
onStreamingUpdate: (streamingMessageId: string | null) => void;
onThoughtUpdate: (thought: string | null) => void;
onToolCallStarted: (toolName: string) => void;
onPlanningStarted: () => void;
onPlanningFinished: () => void;
onUserQuestion: (question: string) => Promise<string>;

View file

@ -14,6 +14,7 @@ export interface IStreamChunk {
error?: string;
errorType?: ApiErrorType;
toolCall?: AIToolCall;
toolCallStarted?: string;
shouldContinue?: boolean;
}