From c6950f8365e35cf12033cb725e1f03dc93045605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 9 Mar 2026 12:37:42 -0300 Subject: [PATCH] fix(vscode): resolve permission prompt hangs in VS Code extension - Handle permission.replied SSE events: map them to permissionResolved messages so cascaded and server-resolved permissions clear the webview prompt without user action - Remove optimistic permission removal: replace with a respondingPermissions Set that disables buttons while awaiting server confirmation, then the SSE-driven permissionResolved clears the prompt - Notify webview on HTTP failure: handlePermissionResponse now posts permissionError to re-enable buttons and show a toast instead of silently swallowing the error - Migrate from deprecated permission.respond to permission.reply endpoint, fixing a missing await bug in the legacy server handler - Recover missed permissions on reconnect and session load: new fetchAndSendPendingPermissions() calls GET /permission after SSE reconnects and after loadMessages, so events lost during reconnection or while the webview was not ready are replayed --- packages/kilo-vscode/src/KiloProvider.ts | 44 +++++++- .../kilo-vscode/src/kilo-provider-utils.ts | 7 ++ .../tests/unit/kilo-provider-utils.test.ts | 37 +++++++ .../src/components/chat/AssistantMessage.tsx | 100 +++++++++--------- .../src/components/chat/ChatView.tsx | 29 +++-- .../webview-ui/src/context/session.tsx | 45 +++++++- .../webview-ui/src/types/messages.ts | 12 +++ 7 files changed, 211 insertions(+), 63 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 16f867798fd..6f1e873287a 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -647,6 +647,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } await this.syncWebviewState("sse-connected") await this.flushPendingSessionRefresh("sse-connected") + await this.fetchAndSendPendingPermissions() } catch (error) { console.error("[Kilo New] KiloProvider: ❌ Failed during connected state handling:", error) this.postMessage({ @@ -825,6 +826,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper sessionID, messages, }) + + // Recover any permission.asked events that were missed while the webview + // was loading or during an SSE reconnection (fire-and-forget). + void this.fetchAndSendPendingPermissions() } catch (error) { // Silently ignore aborted requests — the user switched to a different session if (abort.signal.aborted) return @@ -1515,23 +1520,58 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper response: "once" | "always" | "reject", ): Promise { if (!this.client) { + this.postMessage({ type: "permissionError", permissionID: permissionId }) return } const targetSessionID = sessionID || this.currentSession?.id if (!targetSessionID) { console.error("[Kilo New] KiloProvider: No sessionID for permission response") + this.postMessage({ type: "permissionError", permissionID: permissionId }) return } try { const workspaceDir = this.getWorkspaceDirectory(targetSessionID) - await this.client.permission.respond( - { sessionID: targetSessionID, permissionID: permissionId, response, directory: workspaceDir }, + await this.client.permission.reply( + { requestID: permissionId, reply: response, directory: workspaceDir }, { throwOnError: true }, ) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to respond to permission:", error) + this.postMessage({ type: "permissionError", permissionID: permissionId }) + } + } + + /** + * Fetch all pending permissions from the backend and forward any that belong + * to tracked sessions to the webview. Called after SSE reconnects and after + * loading messages for a session so that missed permission.asked events are + * recovered instead of leaving the server blocked indefinitely. + */ + private async fetchAndSendPendingPermissions(): Promise { + if (!this.client) return + try { + const workspaceDir = this.getWorkspaceDirectory() + const { data } = await this.client.permission.list({ directory: workspaceDir }) + if (!data) return + for (const perm of data) { + if (!this.trackedSessionIds.has(perm.sessionID)) continue + this.postMessage({ + type: "permissionRequest", + permission: { + id: perm.id, + sessionID: perm.sessionID, + toolName: perm.permission, + patterns: perm.patterns, + args: perm.metadata, + message: `Permission required: ${perm.permission}`, + tool: perm.tool, + }, + }) + } + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to fetch pending permissions:", error) } } diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index d409aedcc37..49bf33c4d0b 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -169,6 +169,8 @@ export type WebviewMessage = | { type: "todoUpdated"; sessionID: string; items: unknown[] } | { type: "questionRequest"; question: { id: string; sessionID: string; questions: unknown[]; tool?: unknown } } | { type: "questionResolved"; requestID: string } + | { type: "permissionResolved"; permissionID: string } + | { type: "permissionError"; permissionID: string } | { type: "sessionCreated"; session: ReturnType } | { type: "sessionUpdated"; session: ReturnType } | null @@ -228,6 +230,11 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un tool: event.properties.tool, }, } + case "permission.replied": + return { + type: "permissionResolved", + permissionID: event.properties.requestID, + } case "todo.updated": return { type: "todoUpdated", diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index 32ef47bf193..cc19e449e24 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -17,6 +17,7 @@ import type { EventMessageUpdated, EventSessionStatus, EventPermissionAsked, + EventPermissionReplied, EventTodoUpdated, EventQuestionAsked, EventQuestionReplied, @@ -302,6 +303,42 @@ describe("mapSSEEventToWebviewMessage", () => { } }) + it("maps permission.replied to permissionResolved", () => { + const event: EventPermissionReplied = { + type: "permission.replied", + properties: { sessionID: "sess-1", requestID: "perm-1", reply: "once" }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("permissionResolved") + if (msg?.type === "permissionResolved") { + expect(msg.permissionID).toBe("perm-1") + } + }) + + it("maps permission.replied (always) to permissionResolved", () => { + const event: EventPermissionReplied = { + type: "permission.replied", + properties: { sessionID: "sess-1", requestID: "perm-2", reply: "always" }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("permissionResolved") + if (msg?.type === "permissionResolved") { + expect(msg.permissionID).toBe("perm-2") + } + }) + + it("maps permission.replied (reject) to permissionResolved", () => { + const event: EventPermissionReplied = { + type: "permission.replied", + properties: { sessionID: "sess-1", requestID: "perm-3", reply: "reject" }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("permissionResolved") + if (msg?.type === "permissionResolved") { + expect(msg.permissionID).toBe("perm-3") + } + }) + it("maps todo.updated to todoUpdated", () => { const event: EventTodoUpdated = { type: "todo.updated", diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 830f93b1a69..e85dd8b0755 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -8,11 +8,16 @@ * tool call rather than in the bottom dock. */ -import { Component, For, Show, createMemo, createSignal } from "solid-js" +import { Component, For, Show, createMemo } from "solid-js" import { Dynamic } from "solid-js/web" import { Part, PART_MAPPING, ToolRegistry } from "@kilocode/kilo-ui/message-part" import { Button } from "@kilocode/kilo-ui/button" -import type { AssistantMessage as SDKAssistantMessage, Part as SDKPart, Message as SDKMessage, ToolPart } from "@kilocode/sdk/v2" +import type { + AssistantMessage as SDKAssistantMessage, + Part as SDKPart, + Message as SDKMessage, + ToolPart, +} from "@kilocode/sdk/v2" import { useData } from "@kilocode/kilo-ui/context/data" import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" @@ -101,13 +106,9 @@ export const AssistantMessage: Component = (props) => { // Questions linked to this message (rendered after the last part) const questionForMessage = () => questions().find((q) => q.tool!.messageID === props.message.id) - const [responding, setResponding] = createSignal(false) - const decide = (permissionId: string, response: "once" | "always" | "reject") => { - if (responding()) return - setResponding(true) + if (session.respondingPermissions().has(permissionId)) return session.respondToPermission(permissionId, response) - setResponding(false) } return ( @@ -117,7 +118,8 @@ export const AssistantMessage: Component = (props) => { const perm = () => permissionForPart(part) // Upstream PART_MAPPING["tool"] returns null for todowrite/todoread, // so we detect them here and render via ToolRegistry directly. - const isUpstreamSuppressed = part.type === "tool" && UPSTREAM_SUPPRESSED_TOOLS.has((part as SDKPart & { tool: string }).tool) + const isUpstreamSuppressed = + part.type === "tool" && UPSTREAM_SUPPRESSED_TOOLS.has((part as SDKPart & { tool: string }).tool) return (
@@ -141,49 +143,49 @@ export const AssistantMessage: Component = (props) => { // For other tools: show patterns only if they're meaningful (not just '*') const meaningfulPatterns = p.patterns.filter((pat) => pat !== "*") return ( -
e.stopPropagation()}> - 0}> -
- - {(pattern) => {pattern}} - +
e.stopPropagation()}> + 0}> +
+ + {(pattern) => {pattern}} + +
+
+ +

+ {p.toolName === "todowrite" + ? language.t("settings.permissions.tool.todowrite.description") + : language.t("settings.permissions.tool.todoread.description")} +

+
+
+ + +
- - -

- {p.toolName === "todowrite" - ? language.t("settings.permissions.tool.todowrite.description") - : language.t("settings.permissions.tool.todoread.description")} -

-
-
- - - +

{language.t("ui.permission.sessionHint")}

-

{language.t("ui.permission.sessionHint")}

-
) }} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index a660ebdcca3..bbefc03c335 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -3,7 +3,7 @@ * Main chat container that combines all chat components */ -import { Component, For, Show, createSignal, createEffect, on, onCleanup, onMount } from "solid-js" +import { Component, For, Show, createEffect, on, onCleanup, onMount } from "solid-js" import { Button } from "@kilocode/kilo-ui/button" import { Icon } from "@kilocode/kilo-ui/icon" import { BasicTool } from "@kilocode/kilo-ui/basic-tool" @@ -54,8 +54,6 @@ export const ChatView: Component = (props) => { }), ) - const [responding, setResponding] = createSignal(false) - onMount(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Escape" && session.status() === "busy") { @@ -69,10 +67,8 @@ export const ChatView: Component = (props) => { const decide = (response: "once" | "always" | "reject") => { const perm = permissionRequest() - if (!perm || responding()) return - setResponding(true) + if (!perm || session.respondingPermissions().has(perm.id)) return session.respondToPermission(perm.id, response) - setResponding(false) } return ( @@ -111,13 +107,28 @@ export const ChatView: Component = (props) => {
- - -
diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index fb523f07f05..6e626322687 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -92,6 +92,7 @@ interface SessionContextValue { // Pending permission requests permissions: Accessor + respondingPermissions: Accessor> // Pending question requests questions: Accessor @@ -175,6 +176,9 @@ export const SessionProvider: ParentComponent = (props) => { // Pending permissions const [permissions, setPermissions] = createSignal([]) + // Permission IDs that have been responded to but not yet confirmed by the server + const [respondingPermissions, setRespondingPermissions] = createSignal>(new Set()) + // Pending questions const [questions, setQuestions] = createSignal([]) @@ -395,6 +399,14 @@ export const SessionProvider: ParentComponent = (props) => { handlePermissionRequest(message.permission) break + case "permissionResolved": + handlePermissionResolved(message.permissionID) + break + + case "permissionError": + handlePermissionError(message.permissionID) + break + case "todoUpdated": handleTodoUpdated(message.sessionID, message.items) break @@ -601,6 +613,30 @@ export const SessionProvider: ParentComponent = (props) => { setPermissions((prev) => upsertPermission(prev, permission)) } + function handlePermissionResolved(permissionID: string) { + setPermissions((prev) => prev.filter((p) => p.id !== permissionID)) + setRespondingPermissions((prev) => { + if (!prev.has(permissionID)) return prev + const next = new Set(prev) + next.delete(permissionID) + return next + }) + } + + function handlePermissionError(permissionID: string) { + // Remove from responding set so buttons re-enable (permission prompt is still visible) + setRespondingPermissions((prev) => { + if (!prev.has(permissionID)) return prev + const next = new Set(prev) + next.delete(permissionID) + return next + }) + showToast({ + variant: "error", + title: language.t("settings.permissions.toast.updateFailed.title"), + }) + } + function handleQuestionRequest(question: QuestionRequest) { setQuestions((prev) => { const idx = prev.findIndex((q) => q.id === question.id) @@ -904,15 +940,16 @@ export const SessionProvider: ParentComponent = (props) => { const permission = permissions().find((p) => p.id === permissionId) const sessionID = permission?.sessionID ?? currentSessionID() ?? "" + // Mark as responding so the UI disables the buttons. + // The permission is removed when the server confirms via permission.replied SSE. + setRespondingPermissions((prev) => new Set(prev).add(permissionId)) + vscode.postMessage({ type: "permissionResponse", permissionId, sessionID, response, }) - - // Remove from pending permissions - setPermissions((prev) => prev.filter((p) => p.id !== permissionId)) } function clearQuestionError(requestID: string) { @@ -957,6 +994,7 @@ export const SessionProvider: ParentComponent = (props) => { setCloudPreviewId(null) setLoading(false) setPermissions([]) + setRespondingPermissions(new Set()) setQuestions([]) setQuestionErrors(new Set()) setPendingAgentSelection(defaultAgent()) @@ -1097,6 +1135,7 @@ export const SessionProvider: ParentComponent = (props) => { getParts, todos, permissions, + respondingPermissions, questions, questionErrors, selected, diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 2742267e65a..2a7f1b6a9f8 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -386,6 +386,16 @@ export interface PermissionRequestMessage { permission: PermissionRequest } +export interface PermissionResolvedMessage { + type: "permissionResolved" + permissionID: string +} + +export interface PermissionErrorMessage { + type: "permissionError" + permissionID: string +} + export interface TodoUpdatedMessage { type: "todoUpdated" sessionID: string @@ -955,6 +965,8 @@ export type ExtensionMessage = | PartUpdatedMessage | SessionStatusMessage | PermissionRequestMessage + | PermissionResolvedMessage + | PermissionErrorMessage | TodoUpdatedMessage | SessionCreatedMessage | SessionUpdatedMessage