("permission-allow-always").disabled).toBe(true);
+ });
+});
diff --git a/apps/web/components/task/chat/messages/permission-action-row.tsx b/apps/web/components/task/chat/messages/permission-action-row.tsx
index 7236562e26..f010b67602 100644
--- a/apps/web/components/task/chat/messages/permission-action-row.tsx
+++ b/apps/web/components/task/chat/messages/permission-action-row.tsx
@@ -1,27 +1,35 @@
"use client";
import { memo } from "react";
-import { IconCheck, IconX } from "@tabler/icons-react";
+import { IconCheck, IconChecks, IconX } from "@tabler/icons-react";
import { GridSpinner } from "@/components/grid-spinner";
import { Button } from "@kandev/ui/button";
type PermissionActionRowProps = {
onApprove: () => void;
onReject: () => void;
+ // onAllowAlways is provided only when the agent offers an allow_always
+ // option (e.g. Cursor). When undefined the "Always allow" button is hidden,
+ // preserving the plain Approve/Deny row for agents without it.
+ onAllowAlways?: () => void;
isResponding?: boolean;
};
+const ACTION_BUTTON_CLASS =
+ "h-6 px-3 text-foreground border-border bg-background hover:bg-muted hover:border-foreground/40 transition-colors cursor-pointer";
+
export const PermissionActionRow = memo(function PermissionActionRow({
onApprove,
onReject,
+ onAllowAlways,
isResponding = false,
}: PermissionActionRowProps) {
return (
-
+
Approve this action?
+ {onAllowAlways && (
+
+ )}
);
});
diff --git a/apps/web/components/task/chat/messages/permission-request-message.tsx b/apps/web/components/task/chat/messages/permission-request-message.tsx
index 224b996ce2..a7b665e8f9 100644
--- a/apps/web/components/task/chat/messages/permission-request-message.tsx
+++ b/apps/web/components/task/chat/messages/permission-request-message.tsx
@@ -46,10 +46,11 @@ type PermissionRequestMessageProps = {
export function PermissionRequestMessage({ comment }: PermissionRequestMessageProps) {
const { permissionMetadata, permissionStatus, isPermissionPending } = parsePermission(comment);
- const { isResponding, handleApprove, handleReject } = usePermissionResponseHandlers({
- permissionMetadata,
- permissionMessage: comment,
- });
+ const { isResponding, handleApprove, handleAllowAlways, hasAllowAlways, handleReject } =
+ usePermissionResponseHandlers({
+ permissionMetadata,
+ permissionMessage: comment,
+ });
const statusBadge = getPermissionStatusBadge(permissionStatus);
const titleText = comment.content || "Permission Required";
@@ -96,6 +97,7 @@ export function PermissionRequestMessage({ comment }: PermissionRequestMessagePr
diff --git a/apps/web/components/task/chat/messages/tool-call-message.tsx b/apps/web/components/task/chat/messages/tool-call-message.tsx
index 4619805548..bac85ac5cd 100644
--- a/apps/web/components/task/chat/messages/tool-call-message.tsx
+++ b/apps/web/components/task/chat/messages/tool-call-message.tsx
@@ -71,6 +71,7 @@ type ToolCallExpandedContentProps = {
isPermissionPending: boolean;
onApprove: () => void;
onReject: () => void;
+ onAllowAlways?: () => void;
isResponding: boolean;
};
@@ -80,6 +81,7 @@ function ToolCallExpandedContent({
isPermissionPending,
onApprove,
onReject,
+ onAllowAlways,
isResponding,
}: ToolCallExpandedContentProps) {
return (
@@ -99,6 +101,7 @@ function ToolCallExpandedContent({
)}
@@ -179,10 +182,11 @@ export const ToolCallMessage = memo(function ToolCallMessage({
inlineOutput,
isSuccess,
} = parseToolCallMetadata(comment, permissionMessage);
- const { isResponding, handleApprove, handleReject } = usePermissionResponseHandlers({
- permissionMetadata,
- permissionMessage,
- });
+ const { isResponding, handleApprove, handleAllowAlways, hasAllowAlways, handleReject } =
+ usePermissionResponseHandlers({
+ permissionMetadata,
+ permissionMessage,
+ });
const autoExpanded = status === "running" || isPermissionPending;
const { isExpanded, handleToggle } = useExpandState(status, autoExpanded);
@@ -234,6 +238,7 @@ export const ToolCallMessage = memo(function ToolCallMessage({
isPermissionPending={isPermissionPending}
onApprove={handleApprove}
onReject={handleReject}
+ onAllowAlways={hasAllowAlways ? handleAllowAlways : undefined}
isResponding={isResponding}
/>
diff --git a/apps/web/components/task/chat/messages/use-permission-handlers.test.ts b/apps/web/components/task/chat/messages/use-permission-handlers.test.ts
index 317256d9df..a5c95baf5c 100644
--- a/apps/web/components/task/chat/messages/use-permission-handlers.test.ts
+++ b/apps/web/components/task/chat/messages/use-permission-handlers.test.ts
@@ -1,8 +1,12 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { renderHook, act } from "@testing-library/react";
+import { renderHook, act, type RenderHookResult } from "@testing-library/react";
import { sessionId as toSessionId } from "@/lib/types/http";
import type { Message } from "@/lib/types/http";
-import type { PermissionRequestMetadata } from "./use-permission-handlers";
+import {
+ usePermissionResponseHandlers,
+ type PermissionOption,
+ type PermissionRequestMetadata,
+} from "./use-permission-handlers";
const requestMock = vi.fn().mockResolvedValue({});
@@ -10,104 +14,137 @@ vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => ({ request: requestMock }),
}));
-describe("usePermissionResponseHandlers", () => {
- let usePermissionResponseHandlers: typeof import("./use-permission-handlers").usePermissionResponseHandlers;
+beforeEach(() => {
+ requestMock.mockReset().mockResolvedValue({});
+});
- beforeEach(async () => {
- vi.resetModules();
- requestMock.mockReset().mockResolvedValue({});
- ({ usePermissionResponseHandlers } = await import("./use-permission-handlers"));
+const ALLOW_ONCE: PermissionOption = { option_id: "allow", name: "Allow", kind: "allow_once" };
+const REJECT_ONCE: PermissionOption = { option_id: "deny", name: "Deny", kind: "reject_once" };
+const ALLOW_ALWAYS: PermissionOption = {
+ option_id: "always",
+ name: "Allow always",
+ kind: "allow_always",
+};
+
+function makePermissionMessage(options: PermissionOption[] = [ALLOW_ONCE, REJECT_ONCE]): Message {
+ const meta: PermissionRequestMetadata = {
+ pending_id: "pend-1",
+ tool_call_id: "tc-1",
+ action_type: "mcp_tool",
+ action_details: {},
+ options,
+ };
+ return {
+ id: "msg-1",
+ session_id: toSessionId("sess-1"),
+ task_id: "task-1" as ReturnType,
+ author_type: "agent",
+ content: "",
+ type: "permission_request",
+ created_at: "2026-05-25T00:00:00Z",
+ metadata: meta as unknown as Record,
+ } as unknown as Message;
+}
+
+type Handlers = ReturnType;
+
+function renderHandlers(message: Message): RenderHookResult["result"] {
+ return renderHook(() =>
+ usePermissionResponseHandlers({
+ permissionMetadata: message.metadata as unknown as PermissionRequestMetadata,
+ permissionMessage: message,
+ }),
+ ).result;
+}
+
+function firstPayload(): Record {
+ return requestMock.mock.calls[0][1] as Record;
+}
+
+describe("handleApprove", () => {
+ it("sends cancelled=false and rejected=false", async () => {
+ const result = renderHandlers(makePermissionMessage());
+ await act(async () => {
+ result.current.handleApprove();
+ });
+
+ expect(requestMock).toHaveBeenCalledOnce();
+ expect(firstPayload().option_id).toBe("allow");
+ expect(firstPayload().cancelled).toBeFalsy();
+ expect(firstPayload().rejected).toBeFalsy();
});
- function makePermissionMessage(overrides: Partial = {}): Message {
- const meta: PermissionRequestMetadata = {
- pending_id: "pend-1",
- tool_call_id: "tc-1",
- action_type: "mcp_tool",
- action_details: {},
- options: [
- { option_id: "allow", name: "Allow", kind: "allow_once" },
- { option_id: "deny", name: "Deny", kind: "reject_once" },
- ],
- ...overrides,
- };
- return {
- id: "msg-1",
- session_id: toSessionId("sess-1"),
- task_id: "task-1" as ReturnType,
- author_type: "agent",
- content: "",
- type: "permission_request",
- created_at: "2026-05-25T00:00:00Z",
- metadata: meta as unknown as Record,
- } as unknown as Message;
- }
-
- describe("handleApprove", () => {
- it("sends cancelled=false and rejected=false", async () => {
- const permissionMessage = makePermissionMessage();
- const { result } = renderHook(() =>
- usePermissionResponseHandlers({
- permissionMetadata: permissionMessage.metadata as unknown as PermissionRequestMetadata,
- permissionMessage,
- }),
- );
-
- await act(async () => {
- result.current.handleApprove();
- });
-
- expect(requestMock).toHaveBeenCalledOnce();
- const payload = requestMock.mock.calls[0][1] as Record;
- expect(payload.option_id).toBe("allow");
- expect(payload.cancelled).toBeFalsy();
- expect(payload.rejected).toBeFalsy();
+ it("prefers allow_once over allow_always so 'Always allow' stays distinct", async () => {
+ const once: PermissionOption = { option_id: "once", name: "Allow once", kind: "allow_once" };
+ const result = renderHandlers(makePermissionMessage([ALLOW_ALWAYS, once, REJECT_ONCE]));
+ await act(async () => {
+ result.current.handleApprove();
});
+
+ expect(firstPayload().option_id).toBe("once");
});
- describe("handleReject", () => {
- it("sends rejected=true and cancelled=false when a reject option exists", async () => {
- const permissionMessage = makePermissionMessage();
- const { result } = renderHook(() =>
- usePermissionResponseHandlers({
- permissionMetadata: permissionMessage.metadata as unknown as PermissionRequestMetadata,
- permissionMessage,
- }),
- );
-
- await act(async () => {
- result.current.handleReject();
- });
-
- expect(requestMock).toHaveBeenCalledOnce();
- const payload = requestMock.mock.calls[0][1] as Record;
- expect(payload.option_id).toBe("deny");
- expect(payload.rejected).toBe(true);
- // Must NOT set cancelled=true: that triggers EventTypePermissionCancelled
- // which races against the orchestrator's UpdatePermissionMessage("rejected")
- // and would overwrite the status to "expired".
- expect(payload.cancelled).toBeFalsy();
+ it("falls back to allow_always when no allow_once is offered", async () => {
+ const result = renderHandlers(makePermissionMessage([ALLOW_ALWAYS]));
+ await act(async () => {
+ result.current.handleApprove();
});
- it("falls back to cancelled=true when no reject option exists", async () => {
- const permissionMessage = makePermissionMessage({
- options: [{ option_id: "allow", name: "Allow", kind: "allow_once" }],
- });
- const { result } = renderHook(() =>
- usePermissionResponseHandlers({
- permissionMetadata: permissionMessage.metadata as unknown as PermissionRequestMetadata,
- permissionMessage,
- }),
- );
-
- await act(async () => {
- result.current.handleReject();
- });
-
- expect(requestMock).toHaveBeenCalledOnce();
- const payload = requestMock.mock.calls[0][1] as Record;
- expect(payload.cancelled).toBe(true);
- expect(payload.option_id).toBeUndefined();
- });
+ expect(firstPayload().option_id).toBe("always");
+ });
+});
+
+describe("handleAllowAlways", () => {
+ it("hasAllowAlways is true and responds with the allow_always option (not rejected)", async () => {
+ const result = renderHandlers(makePermissionMessage([ALLOW_ONCE, ALLOW_ALWAYS, REJECT_ONCE]));
+ expect(result.current.hasAllowAlways).toBe(true);
+
+ await act(async () => {
+ result.current.handleAllowAlways();
+ });
+
+ expect(requestMock).toHaveBeenCalledOnce();
+ expect(firstPayload().option_id).toBe("always");
+ expect(firstPayload().cancelled).toBeFalsy();
+ expect(firstPayload().rejected).toBeFalsy();
+ });
+
+ it("hasAllowAlways is false and handleAllowAlways is a no-op when not offered", async () => {
+ const result = renderHandlers(makePermissionMessage([ALLOW_ONCE, REJECT_ONCE]));
+ expect(result.current.hasAllowAlways).toBe(false);
+
+ await act(async () => {
+ result.current.handleAllowAlways();
+ });
+
+ expect(requestMock).not.toHaveBeenCalled();
+ });
+});
+
+describe("handleReject", () => {
+ it("sends rejected=true and cancelled=false when a reject option exists", async () => {
+ const result = renderHandlers(makePermissionMessage());
+ await act(async () => {
+ result.current.handleReject();
+ });
+
+ expect(requestMock).toHaveBeenCalledOnce();
+ expect(firstPayload().option_id).toBe("deny");
+ expect(firstPayload().rejected).toBe(true);
+ // Must NOT set cancelled=true: that triggers EventTypePermissionCancelled
+ // which races against the orchestrator's UpdatePermissionMessage("rejected")
+ // and would overwrite the status to "expired".
+ expect(firstPayload().cancelled).toBeFalsy();
+ });
+
+ it("falls back to cancelled=true when no reject option exists", async () => {
+ const result = renderHandlers(makePermissionMessage([ALLOW_ONCE]));
+ await act(async () => {
+ result.current.handleReject();
+ });
+
+ expect(requestMock).toHaveBeenCalledOnce();
+ expect(firstPayload().cancelled).toBe(true);
+ expect(firstPayload().option_id).toBeUndefined();
});
});
diff --git a/apps/web/components/task/chat/messages/use-permission-handlers.ts b/apps/web/components/task/chat/messages/use-permission-handlers.ts
index 581a382f53..7e882f6fb2 100644
--- a/apps/web/components/task/chat/messages/use-permission-handlers.ts
+++ b/apps/web/components/task/chat/messages/use-permission-handlers.ts
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import { getWebSocketClient } from "@/lib/ws/connection";
import type { Message } from "@/lib/types/http";
import type { PermissionActionType, PermissionOptionKind } from "@/lib/types/permission";
@@ -85,13 +85,29 @@ export function usePermissionResponseHandlers({
[permissionMessage, permissionMetadata],
);
+ // "Approve" is the one-shot allow. Prefer an explicit allow_once option and
+ // only fall back to allow_always when the agent offers nothing else, so the
+ // dedicated "Always allow" button (handleAllowAlways) stays distinct.
const handleApprove = useCallback(() => {
- const allowOption = permissionMetadata?.options.find(
- (opt) => opt.kind === "allow_once" || opt.kind === "allow_always",
- );
+ const options = permissionMetadata?.options ?? [];
+ const allowOption =
+ options.find((opt) => opt.kind === "allow_once") ??
+ options.find((opt) => opt.kind === "allow_always");
if (allowOption) handleRespond(allowOption.option_id);
}, [permissionMetadata, handleRespond]);
+ // "Always allow" maps to the agent's allow_always option, telling the agent
+ // to persist the decision so the same action is not re-prompted. Only some
+ // agents offer it (Cursor does); hasAllowAlways gates the button.
+ const allowAlwaysOption = useMemo(
+ () => permissionMetadata?.options.find((opt) => opt.kind === "allow_always"),
+ [permissionMetadata],
+ );
+ const hasAllowAlways = !!allowAlwaysOption;
+ const handleAllowAlways = useCallback(() => {
+ if (allowAlwaysOption) handleRespond(allowAlwaysOption.option_id);
+ }, [allowAlwaysOption, handleRespond]);
+
const handleReject = useCallback(() => {
const rejectOption = permissionMetadata?.options.find(
(opt) => opt.kind === "reject_once" || opt.kind === "reject_always",
@@ -106,5 +122,5 @@ export function usePermissionResponseHandlers({
}
}, [permissionMetadata, handleRespond]);
- return { isResponding, handleApprove, handleReject };
+ return { isResponding, handleApprove, handleAllowAlways, hasAllowAlways, handleReject };
}