mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
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
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof sessionToWebview> }
|
||||
| { type: "sessionUpdated"; session: ReturnType<typeof sessionToWebview> }
|
||||
| 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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<AssistantMessageProps> = (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<AssistantMessageProps> = (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 (
|
||||
<Show when={isUpstreamSuppressed || PART_MAPPING[part.type]}>
|
||||
<div data-component="tool-part-wrapper" data-permission={!!perm()} data-part-type={part.type}>
|
||||
@@ -141,49 +143,49 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
|
||||
// For other tools: show patterns only if they're meaningful (not just '*')
|
||||
const meaningfulPatterns = p.patterns.filter((pat) => pat !== "*")
|
||||
return (
|
||||
<div data-component="permission-prompt" onClick={(e: MouseEvent) => e.stopPropagation()}>
|
||||
<Show when={!isTodoPerm && meaningfulPatterns.length > 0}>
|
||||
<div class="permission-dock-patterns">
|
||||
<For each={meaningfulPatterns}>
|
||||
{(pattern) => <code class="permission-dock-pattern">{pattern}</code>}
|
||||
</For>
|
||||
<div data-component="permission-prompt" onClick={(e: MouseEvent) => e.stopPropagation()}>
|
||||
<Show when={!isTodoPerm && meaningfulPatterns.length > 0}>
|
||||
<div class="permission-dock-patterns">
|
||||
<For each={meaningfulPatterns}>
|
||||
{(pattern) => <code class="permission-dock-pattern">{pattern}</code>}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={isTodoPerm}>
|
||||
<p data-slot="permission-description">
|
||||
{p.toolName === "todowrite"
|
||||
? language.t("settings.permissions.tool.todowrite.description")
|
||||
: language.t("settings.permissions.tool.todoread.description")}
|
||||
</p>
|
||||
</Show>
|
||||
<div data-slot="permission-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => decide(p.id, "reject")}
|
||||
disabled={session.respondingPermissions().has(p.id)}
|
||||
>
|
||||
{language.t("ui.permission.deny")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => decide(p.id, "always")}
|
||||
disabled={session.respondingPermissions().has(p.id)}
|
||||
>
|
||||
{language.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={() => decide(p.id, "once")}
|
||||
disabled={session.respondingPermissions().has(p.id)}
|
||||
>
|
||||
{language.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={isTodoPerm}>
|
||||
<p data-slot="permission-description">
|
||||
{p.toolName === "todowrite"
|
||||
? language.t("settings.permissions.tool.todowrite.description")
|
||||
: language.t("settings.permissions.tool.todoread.description")}
|
||||
</p>
|
||||
</Show>
|
||||
<div data-slot="permission-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => decide(p.id, "reject")}
|
||||
disabled={responding()}
|
||||
>
|
||||
{language.t("ui.permission.deny")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => decide(p.id, "always")}
|
||||
disabled={responding()}
|
||||
>
|
||||
{language.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={() => decide(p.id, "once")}
|
||||
disabled={responding()}
|
||||
>
|
||||
{language.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
<p data-slot="permission-hint">{language.t("ui.permission.sessionHint")}</p>
|
||||
</div>
|
||||
<p data-slot="permission-hint">{language.t("ui.permission.sessionHint")}</p>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
@@ -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<ChatViewProps> = (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<ChatViewProps> = (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<ChatViewProps> = (props) => {
|
||||
</BasicTool>
|
||||
<div data-component="permission-prompt">
|
||||
<div data-slot="permission-actions">
|
||||
<Button variant="ghost" size="small" onClick={() => decide("reject")} disabled={responding()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => decide("reject")}
|
||||
disabled={session.respondingPermissions().has(perm.id)}
|
||||
>
|
||||
{language.t("ui.permission.deny")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="small" onClick={() => decide("always")} disabled={responding()}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => decide("always")}
|
||||
disabled={session.respondingPermissions().has(perm.id)}
|
||||
>
|
||||
{language.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Button variant="primary" size="small" onClick={() => decide("once")} disabled={responding()}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={() => decide("once")}
|
||||
disabled={session.respondingPermissions().has(perm.id)}
|
||||
>
|
||||
{language.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -92,6 +92,7 @@ interface SessionContextValue {
|
||||
|
||||
// Pending permission requests
|
||||
permissions: Accessor<PermissionRequest[]>
|
||||
respondingPermissions: Accessor<Set<string>>
|
||||
|
||||
// Pending question requests
|
||||
questions: Accessor<QuestionRequest[]>
|
||||
@@ -175,6 +176,9 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
// Pending permissions
|
||||
const [permissions, setPermissions] = createSignal<PermissionRequest[]>([])
|
||||
|
||||
// Permission IDs that have been responded to but not yet confirmed by the server
|
||||
const [respondingPermissions, setRespondingPermissions] = createSignal<Set<string>>(new Set())
|
||||
|
||||
// Pending questions
|
||||
const [questions, setQuestions] = createSignal<QuestionRequest[]>([])
|
||||
|
||||
@@ -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<string>())
|
||||
setQuestions([])
|
||||
setQuestionErrors(new Set<string>())
|
||||
setPendingAgentSelection(defaultAgent())
|
||||
@@ -1097,6 +1135,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
getParts,
|
||||
todos,
|
||||
permissions,
|
||||
respondingPermissions,
|
||||
questions,
|
||||
questionErrors,
|
||||
selected,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user