Add tool confirmation UI with Accept/Decline for write operations

Before executing any write tool (create/update/delete), the backend now
pauses with an asyncio.Future and emits a tool_pending SSE event. The
frontend displays a ToolConfirmCard with Accept and Decline buttons.
Clicking Accept resolves the Future and proceeds; Decline records a
declined tool_call chip and falls through to regular streaming. Typing
single-word yes/no responses (e.g. "yes", "cancel") also works as
confirmation. 120s timeout auto-declines if no response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 20:43:47 -05:00
parent 1b63371bb3
commit 815eed2574
8 changed files with 304 additions and 43 deletions
+13 -2
View File
@@ -11,6 +11,7 @@ const appliedTags = ref<Set<string>>(new Set());
const applyingTag = ref<string | null>(null);
const label = computed(() => {
if (props.toolCall.status === "declined") return "Declined";
if (!props.toolCall.result.success) return "Error";
switch (props.toolCall.result.type) {
case "task":
@@ -180,9 +181,12 @@ async function applyTag(tag: string) {
</script>
<template>
<div class="tool-call-card" :class="{ error: toolCall.status === 'error' }">
<div class="tool-call-card" :class="{ error: toolCall.status === 'error', declined: toolCall.status === 'declined' }">
<span class="tool-label">{{ label }}</span>
<template v-if="toolCall.status === 'error'">
<template v-if="toolCall.status === 'declined'">
<span class="tool-declined-name">{{ toolCall.arguments.title ?? toolCall.arguments.summary ?? toolCall.arguments.query ?? toolCall.function }}</span>
</template>
<template v-else-if="toolCall.status === 'error'">
<span class="tool-error">{{ toolCall.result.error }}</span>
</template>
<template v-else-if="searchResults">
@@ -292,6 +296,13 @@ async function applyTag(tag: string) {
.tool-call-card.error {
border-color: var(--color-danger, #e74c3c);
}
.tool-call-card.declined {
opacity: 0.55;
}
.tool-declined-name {
text-decoration: line-through;
color: var(--color-text-muted);
}
.tool-label {
font-weight: 600;
color: var(--color-text-muted);
+108
View File
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed } from "vue";
import type { ToolPendingRecord } from "@/types/chat";
const props = defineProps<{
pendingTool: ToolPendingRecord;
}>();
const emit = defineEmits<{
(e: "accept"): void;
(e: "decline"): void;
}>();
const label = computed(() => props.pendingTool.label ?? props.pendingTool.function);
const detail = computed(() => {
const args = props.pendingTool.arguments;
const name =
(args.title as string | undefined) ??
(args.summary as string | undefined) ??
(args.query as string | undefined) ??
"";
return name ? `"${name}"` : "";
});
</script>
<template>
<div class="tool-confirm-card">
<div class="tool-confirm-info">
<span class="tool-confirm-label">{{ label }}</span>
<span v-if="detail" class="tool-confirm-detail">{{ detail }}</span>
</div>
<div class="tool-confirm-actions">
<button class="btn-accept" @click="emit('accept')">Accept</button>
<button class="btn-decline" @click="emit('decline')">Decline</button>
</div>
</div>
</template>
<style scoped>
.tool-confirm-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-warning, #f59e0b);
border-radius: 12px;
padding: 0.45rem 0.75rem;
font-size: 0.85rem;
margin-top: 0.4rem;
width: 100%;
box-sizing: border-box;
}
.tool-confirm-info {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
flex: 1;
}
.tool-confirm-label {
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
font-size: 0.7rem;
white-space: nowrap;
}
.tool-confirm-detail {
color: var(--color-text);
font-size: 0.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-confirm-actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
}
.btn-accept,
.btn-decline {
padding: 0.25rem 0.65rem;
border: none;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-accept {
background: var(--color-success, #2ecc71);
color: #fff;
}
.btn-decline {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
.btn-accept:hover {
opacity: 0.85;
}
.btn-decline:hover {
color: var(--color-danger, #e74c3c);
border-color: var(--color-danger, #e74c3c);
}
</style>
+40
View File
@@ -17,6 +17,7 @@ import type {
OllamaStatus,
SendMessageResponse,
ToolCallRecord,
ToolPendingRecord,
} from "@/types/chat";
export const useChatStore = defineStore("chat", () => {
@@ -28,6 +29,7 @@ export const useChatStore = defineStore("chat", () => {
const streamingContent = ref("");
const streamingToolCalls = ref<ToolCallRecord[]>([]);
const streamingStatus = ref("");
const streamingPendingTool = ref<ToolPendingRecord | null>(null);
const lastContextMeta = ref<ContextMeta | null>(null);
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
const modelStatus = ref<"checking" | "loaded" | "cold" | "not_found">("checking");
@@ -124,6 +126,22 @@ export const useChatStore = defineStore("chat", () => {
const convId = currentConversation.value.id;
lastContextMeta.value = null;
// If a write tool is waiting for confirmation, intercept single-word yes/no
// responses rather than sending them as a new message.
if (streamingPendingTool.value) {
const lower = content.trim().toLowerCase();
const YES = new Set(["yes", "y", "ok", "sure", "proceed", "accept", "confirm", "do it", "go ahead", "yeah", "yep"]);
const NO = new Set(["no", "n", "cancel", "decline", "stop", "nope", "abort", "don't"]);
if (YES.has(lower)) {
await confirmTool(true);
return;
}
if (NO.has(lower)) {
await confirmTool(false);
return;
}
}
// Add optimistic user message
const userMsg: Message = {
id: -Date.now(), // Temporary ID
@@ -188,7 +206,12 @@ export const useChatStore = defineStore("chat", () => {
streamingContent.value += event.data.chunk as string;
streamingStatus.value = "";
break;
case "tool_pending":
streamingPendingTool.value = event.data.tool_pending as ToolPendingRecord;
break;
case "tool_call":
// Receiving a tool_call clears the pending confirmation card
streamingPendingTool.value = null;
streamingToolCalls.value = [
...streamingToolCalls.value,
event.data.tool_call as ToolCallRecord,
@@ -215,6 +238,7 @@ export const useChatStore = defineStore("chat", () => {
streamingContent.value = "";
streamingToolCalls.value = [];
streamingStatus.value = "";
streamingPendingTool.value = null;
streaming.value = false;
// Update conversation in list
@@ -231,6 +255,7 @@ export const useChatStore = defineStore("chat", () => {
streamingContent.value = "";
streamingToolCalls.value = [];
streamingStatus.value = "";
streamingPendingTool.value = null;
useToastStore().show(
"Chat error: " + (event.data.error as string),
"error",
@@ -269,6 +294,19 @@ export const useChatStore = defineStore("chat", () => {
streamingContent.value = "";
streamingToolCalls.value = [];
streamingStatus.value = "";
streamingPendingTool.value = null;
}
async function confirmTool(confirmed: boolean) {
const convId = currentConversation.value?.id;
if (!convId) return;
// Optimistically clear so buttons disappear immediately
streamingPendingTool.value = null;
try {
await apiPost(`/api/chat/conversations/${convId}/generation/confirm`, { confirmed });
} catch {
// Server will auto-decline on timeout — no action needed
}
}
async function cancelGeneration() {
@@ -353,6 +391,7 @@ export const useChatStore = defineStore("chat", () => {
streamingContent,
streamingToolCalls,
streamingStatus,
streamingPendingTool,
lastContextMeta,
ollamaStatus,
modelStatus,
@@ -364,6 +403,7 @@ export const useChatStore = defineStore("chat", () => {
deleteConversation,
updateTitle,
sendMessage,
confirmTool,
cancelGeneration,
saveMessageAsNote,
summarizeAsNote,
+7 -1
View File
@@ -10,7 +10,13 @@ export interface ToolCallRecord {
function: string;
arguments: Record<string, unknown>;
result: { success: boolean; type?: string; data?: Record<string, unknown>; error?: string; suggested_tags?: string[] };
status: "success" | "error";
status: "success" | "error" | "declined";
}
export interface ToolPendingRecord {
function: string;
arguments: Record<string, unknown>;
label?: string;
}
export interface Message {
+7
View File
@@ -7,6 +7,7 @@ import { apiGet } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown";
import ChatMessage from "@/components/ChatMessage.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";
import type { Note } from "@/types/note";
const route = useRoute();
@@ -402,6 +403,12 @@ onUnmounted(() => {
:tool-call="tc"
/>
</div>
<ToolConfirmCard
v-if="store.streamingPendingTool"
:pending-tool="store.streamingPendingTool"
@accept="store.confirmTool(true)"
@decline="store.confirmTool(false)"
/>
<div v-if="store.streamingStatus" class="streaming-status-line">
<span class="streaming-status-dot"></span>
{{ store.streamingStatus }}