From 2bc12691d3ee593866b1a92dd0184e3855d85b73 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Mon, 16 Feb 2026 17:40:14 +0000 Subject: [PATCH] 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. --- AIClasses/Claude/Claude.ts | 7 +++++++ AIClasses/OpenAI/OpenAI.ts | 18 ++++++++++++++++-- AIClasses/OpenAI/OpenAITypes.ts | 13 +++++++++++++ Components/ChatArea.svelte | 2 +- Components/ChatPlanArea.svelte | 2 +- Components/ChatWindow.svelte | 14 ++++++++++++++ Components/Spinner.svelte | 9 +++++++-- Components/ThoughtIndicator.svelte | 12 ++++++++++-- Enums/Copy.ts | 2 ++ Services/AIServices/BaseAgent.ts | 5 +++++ Services/ChatService.ts | 1 + Services/StreamingService.ts | 1 + 12 files changed, 78 insertions(+), 8 deletions(-) diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index 22e009a..6a71549 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -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 + }; } } diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 6e0311d..a449e45 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -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": diff --git a/AIClasses/OpenAI/OpenAITypes.ts b/AIClasses/OpenAI/OpenAITypes.ts index 34c4bb6..0985338 100644 --- a/AIClasses/OpenAI/OpenAITypes.ts +++ b/AIClasses/OpenAI/OpenAITypes.ts @@ -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; diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index b435405..19d62b4 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -289,7 +289,7 @@ {/if} {/each} - + {#if isSubmitting} {/if} diff --git a/Components/ChatPlanArea.svelte b/Components/ChatPlanArea.svelte index c4763b6..ab43110 100644 --- a/Components/ChatPlanArea.svelte +++ b/Components/ChatPlanArea.svelte @@ -114,7 +114,7 @@ {#if busyPlanning}
- + {$executionPlanState.plan?.isReplan ? "Replanning in progress..." : "Planning in progress..."}
{/if} diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index 7e3b91e..78d878b 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -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(Services.VaultkeeperAIPlugin); const executionPlanStore: ExecutionPlanStore = Resolve(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; }, diff --git a/Components/Spinner.svelte b/Components/Spinner.svelte index b913e4b..89e5b4b 100644 --- a/Components/Spinner.svelte +++ b/Components/Spinner.svelte @@ -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; @@ -11,7 +12,7 @@ style="width: {width}; height: {height};" bind:this={spinnerElement} > -
+
diff --git a/Components/ThoughtIndicator.svelte b/Components/ThoughtIndicator.svelte index ceb52de..bfaa621 100644 --- a/Components/ThoughtIndicator.svelte +++ b/Components/ThoughtIndicator.svelte @@ -1,14 +1,19 @@ {#if isVisible}
- {thought} + {thought}
{/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 { diff --git a/Enums/Copy.ts b/Enums/Copy.ts index 615e25d..d584236 100644 --- a/Enums/Copy.ts +++ b/Enums/Copy.ts @@ -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}...", diff --git a/Services/AIServices/BaseAgent.ts b/Services/AIServices/BaseAgent.ts index 89f33dd..12781a2 100644 --- a/Services/AIServices/BaseAgent.ts +++ b/Services/AIServices/BaseAgent.ts @@ -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; diff --git a/Services/ChatService.ts b/Services/ChatService.ts index b1f6562..2b4aa83 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -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; diff --git a/Services/StreamingService.ts b/Services/StreamingService.ts index fa2582f..d90e9b4 100644 --- a/Services/StreamingService.ts +++ b/Services/StreamingService.ts @@ -14,6 +14,7 @@ export interface IStreamChunk { error?: string; errorType?: ApiErrorType; toolCall?: AIToolCall; + toolCallStarted?: string; shouldContinue?: boolean; }