Files
kandev/apps/web/components/task/chat/message-renderer.tsx
T
Luan Martins 1a467b969f fix(web): wire permission approval UI for Kandev MCP tools (#1037)
* fix(web): wire permission approval UI for Kandev MCP tools

KandevToolMessage adapter ignored the permissionMessage prop, so MCP tools
that block on a permission request rendered the result row but no Approve/
Deny buttons — users could not unblock the agent. Extract the shared
permission handlers into use-permission-handlers.ts and reuse them from
both ToolCallMessage and KandevToolMessage. KandevRow now also shows an
amber pending clock icon and hides the result-count summary while waiting
for approval so the row reads correctly before the result arrives.

Adds a mock-agent scenarioKandevMCPPermission emitting
mcp__kandev__list_workspaces_kandev plus a Playwright e2e and unit tests
covering the pending UI and the absence of "0 workspaces" while pending.

* fix(web): collapse tool + permission status into resolved Kandev row

After approve/reject the orchestrator stamps the decision onto the
permission_request message immediately, but the tool_call's own
meta.status stays "pending" until the next tool_call update lands.
KandevToolMessage previously passed meta.status straight to the renderer,
so the row kept the amber pending clock and the hidden-summary state for
the brief window between the two events. resolveKandevStatus now prefers
the permission decision once present, mapping approved -> complete and
rejected -> error.

Drops a redundant Playwright `.first()` after a toHaveCount(1) assertion
on the same locator. Adds unit coverage for the resolved-decision state.

* fix(web): keep Kandev tool status independent of permission decision

The previous fix collapsed an approved permission into tool status
"complete" and a rejected permission into "error". That was wrong on two
counts: an approved tool that's still running rendered as already done
(green check + visible result summary built from an empty payload), and
any later tool_call error was silently masked because the status had
already been forced to "complete".

Replace the status override with a separate display overlay. KandevRow
now reads a `KandevPermissionUIContext` ("pending" | "rejected" |
undefined) and uses it only to drive the waiting-on-user affordances —
amber clock, hidden summary — and a rejection mark that wins over the
tool's own success state. The renderer always receives the real
tool_call status, so a tool that's approved-but-pending still reads as
pending and a tool that errors after approval still renders the red X.

Adds explicit regression tests for the two race-window failure modes
plus the new rejected overlay.

* test(web): tolerate duplicate-render backend race in Kandev MCP e2e

CI shard 1 sometimes renders two approve buttons for the same Kandev MCP
permission because the orchestrator can create a second generic tool_call
message for the same tool_call_id alongside the kandev-routed one. Both
buttons resolve the same pending_id, so clicking the first unblocks the
agent either way.

The bug the test guards against is "no approve button at all" — the
strict toHaveCount(1) assertion was tying the test to that separate
backend issue. Relax to at-least-one and click the first.

* fix: persist Deny clicks as rejected, not approved

Backend RespondToPermission previously derived status purely from cancelled:
cancelled=true -> "rejected", otherwise "approved". Deny clicks sent
cancelled=false with a reject_* option_id, so they were recorded as
"approved". Add an explicit rejected flag from the WS request so the
backend can record "rejected" without taking the cancellation path (which
races against permission_cancelled -> "expired").

Also refactor permission-request-message.tsx to consume the shared
usePermissionResponseHandlers hook (the duplicated inline handler had the
same bug) and introduce a typed models.PermissionStatus enum on the
backend so status strings are not scattered literals.

* test(web): scope Kandev MCP permission e2e to the Kandev row

Previously the test clicked the first matching permission-approve button,
which could be a duplicate generic ToolCallMessage fallback rendered for
the same pending_id by a backend race -- a green test no longer proved the
custom Kandev approval UI rendered.

Add a `data-testid="kandev-tool-permission"` wrapper around the Kandev
action row and a `kandevPermissionApproveButtons` page-object locator that
scopes through it, then assert `toHaveCount(1)` so the test only passes
when the Kandev custom row actually mounts.

* chore(orchestrator): log rejected flag in RespondToPermission

Debug log was missing the new rejected param, making it impossible to
distinguish explicit Deny from dialog cancellation when triaging
permission-flow issues.
2026-05-26 00:22:45 +01:00

335 lines
12 KiB
TypeScript

"use client";
import { memo, useState, useCallback, type ReactElement } from "react";
import { IconPlayerPlay } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import type { Message, TaskSessionState, TaskState } from "@/lib/types/http";
import type { ToolCallMetadata } from "@/components/task/chat/types";
import { launchSession } from "@/lib/services/session-launch-service";
import { buildStartCreatedRequest } from "@/lib/services/session-launch-helpers";
import { useAppStore } from "@/components/state-provider";
import { ChatMessage } from "@/components/task/chat/messages/chat-message";
import { PermissionRequestMessage } from "@/components/task/chat/messages/permission-request-message";
import { StatusMessage } from "@/components/task/chat/messages/status-message";
import { ToolCallMessage } from "@/components/task/chat/messages/tool-call-message";
import { ToolEditMessage } from "@/components/task/chat/messages/tool-edit-message";
import { ToolReadMessage } from "@/components/task/chat/messages/tool-read-message";
import { ToolSearchMessage } from "@/components/task/chat/messages/tool-search-message";
import { ToolExecuteMessage } from "@/components/task/chat/messages/tool-execute-message";
import { ThinkingMessage } from "@/components/task/chat/messages/thinking-message";
import { TodoMessage } from "@/components/task/chat/messages/todo-message";
import { ScriptExecutionMessage } from "@/components/task/chat/messages/script-execution-message";
import { ClarificationRequestMessage } from "@/components/task/chat/messages/clarification-request-message";
import { ToolSubagentMessage } from "@/components/task/chat/messages/tool-subagent-message";
import { MonitorMessage } from "@/components/task/chat/messages/monitor-message";
import { AgentPlanMessage } from "@/components/task/chat/messages/agent-plan-message";
import { ActionMessage } from "@/components/task/chat/messages/action-message";
import {
KandevToolMessage,
hasKandevRenderer,
} from "@/components/task/chat/messages/kandev-tool-message";
type AdapterContext = {
isTaskDescription: boolean;
sessionState?: TaskSessionState;
taskState?: TaskState;
taskId?: string;
permissionsByToolCallId?: Map<string, Message>;
childrenByParentToolCallId?: Map<string, Message[]>;
worktreePath?: string;
sessionId?: string;
onOpenFile?: (path: string) => void;
allMessages?: Message[];
onScrollToMessage?: (messageId: string) => void;
};
function TaskDescriptionStartButton({ taskId, sessionId }: { taskId: string; sessionId: string }) {
const [isStarting, setIsStarting] = useState(false);
const prepareStatus = useAppStore(
(state) => state.prepareProgress.bySessionId[sessionId]?.status ?? null,
);
const handleStart = useCallback(async () => {
setIsStarting(true);
try {
const { request } = buildStartCreatedRequest(taskId, sessionId);
await launchSession(request);
} catch (error) {
console.error("Failed to start agent:", error);
} finally {
setIsStarting(false);
}
}, [taskId, sessionId]);
// Hide while environment is being prepared
if (prepareStatus === "preparing") return null;
return (
<div className="flex justify-end mt-1.5">
<Button
size="sm"
variant="default"
className="cursor-pointer gap-1.5"
onClick={handleStart}
disabled={isStarting}
data-testid="task-description-start-button"
>
<IconPlayerPlay className="h-3.5 w-3.5" />
{isStarting ? "Starting…" : "Start agent"}
</Button>
</div>
);
}
type MessageAdapter = {
matches: (comment: Message, ctx: AdapterContext) => boolean;
render: (comment: Message, ctx: AdapterContext) => ReactElement;
};
const adapters: MessageAdapter[] = [
{
matches: (comment) => comment.type === "thinking",
render: (comment) => <ThinkingMessage comment={comment} />,
},
{
matches: (comment) => comment.type === "todo",
render: (comment) => <TodoMessage comment={comment} />,
},
{
matches: (comment) => comment.type === "tool_edit",
render: (comment, ctx) => (
<ToolEditMessage
comment={comment}
worktreePath={ctx.worktreePath}
onOpenFile={ctx.onOpenFile}
/>
),
},
{
matches: (comment) => comment.type === "tool_read",
render: (comment, ctx) => (
<ToolReadMessage
comment={comment}
worktreePath={ctx.worktreePath}
sessionId={ctx.sessionId}
onOpenFile={ctx.onOpenFile}
/>
),
},
{
matches: (comment) => comment.type === "tool_search",
render: (comment, ctx) => (
<ToolSearchMessage
comment={comment}
worktreePath={ctx.worktreePath}
onOpenFile={ctx.onOpenFile}
/>
),
},
{
matches: (comment) => comment.type === "tool_execute",
render: (comment, ctx) => (
<ToolExecuteMessage comment={comment} worktreePath={ctx.worktreePath} />
),
},
{
// Claude-acp's Monitor tool — long-lived background script with streaming
// events. Rendered with a dedicated card that shows watching state, event
// count, and the most recent event tail. Detected via the structured
// `monitor` view the adapter writes into the Generic payload's output
// wrapper (presence-based rather than title-based so renames upstream
// don't break the match). Must run BEFORE the generic tool_call adapter.
matches: (comment) => {
if (comment.type !== "tool_call") return false;
const meta = comment.metadata as ToolCallMetadata | undefined;
const out = meta?.normalized?.generic?.output as { monitor?: unknown } | undefined;
return !!out && typeof out === "object" && !!out.monitor;
},
render: (comment) => <MonitorMessage comment={comment} />,
},
{
// Subagent Task tool calls with nested children
matches: (comment, ctx) => {
if (comment.type !== "tool_call") return false;
const metadata = comment.metadata as ToolCallMetadata | undefined;
const isSubagent = metadata?.normalized?.kind === "subagent_task";
const toolCallId = metadata?.tool_call_id;
const hasChildren = toolCallId
? (ctx.childrenByParentToolCallId?.has(toolCallId) ?? false)
: false;
return isSubagent || hasChildren;
},
render: (comment, ctx) => {
const toolCallId = (comment.metadata as ToolCallMetadata | undefined)?.tool_call_id;
const childMessages = toolCallId
? (ctx.childrenByParentToolCallId?.get(toolCallId) ?? [])
: [];
// Create a render function for child messages
const renderChild = (child: Message) => {
// Recursively use MessageRenderer for children (without subagent nesting)
const childCtx = { ...ctx, childrenByParentToolCallId: undefined };
const adapter =
adapters.find((entry) => entry.matches(child, childCtx)) ?? adapters[adapters.length - 1];
return adapter.render(child, childCtx);
};
return (
<ToolSubagentMessage
comment={comment}
childMessages={childMessages}
worktreePath={ctx.worktreePath}
onOpenFile={ctx.onOpenFile}
renderChild={renderChild}
/>
);
},
},
{
// Kandev MCP tools — `mcp__kandev__list_tasks_kandev` etc. — get
// per-tool structured rendering. Must run BEFORE the generic tool_call
// adapter so the unwrapped + structured view wins. Unrecognised Kandev
// tools (no matching renderer) fall through to the generic adapter.
matches: (comment) => hasKandevRenderer(comment),
render: (comment, ctx) => {
const toolCallId = (comment.metadata as { tool_call_id?: string } | undefined)?.tool_call_id;
const permissionMessage = toolCallId
? ctx.permissionsByToolCallId?.get(toolCallId)
: undefined;
return <KandevToolMessage comment={comment} permissionMessage={permissionMessage} />;
},
},
{
matches: (comment) => comment.type === "tool_call",
render: (comment, ctx) => {
const toolCallId = (comment.metadata as { tool_call_id?: string } | undefined)?.tool_call_id;
const permissionMessage = toolCallId
? ctx.permissionsByToolCallId?.get(toolCallId)
: undefined;
return (
<ToolCallMessage
comment={comment}
permissionMessage={permissionMessage}
worktreePath={ctx.worktreePath}
/>
);
},
},
{
matches: (comment) => {
const meta = comment.metadata as Record<string, unknown> | undefined;
return Array.isArray(meta?.actions) && (meta.actions as unknown[]).length > 0;
},
render: (comment, ctx) => <ActionMessage comment={comment} sessionState={ctx.sessionState} />,
},
{
matches: (comment) =>
comment.type === "error" || comment.type === "status" || comment.type === "progress",
render: (comment) => <StatusMessage comment={comment} />,
},
{
// Standalone permission requests (no matching tool call)
matches: (comment) => comment.type === "permission_request",
render: (comment) => <PermissionRequestMessage comment={comment} />,
},
{
matches: (comment) => comment.type === "clarification_request",
render: (comment) => <ClarificationRequestMessage comment={comment} />,
},
{
matches: (comment) => comment.type === "agent_plan",
render: (comment) => <AgentPlanMessage comment={comment} />,
},
{
matches: (comment) => comment.type === "script_execution",
render: (comment) => <ScriptExecutionMessage comment={comment} />,
},
{
matches: () => true,
render: (comment, ctx) => {
if (
comment.author_type === "user" ||
(ctx.isTaskDescription && ctx.sessionState !== "FAILED")
) {
const showStartButton =
ctx.isTaskDescription &&
ctx.sessionState === "CREATED" &&
ctx.taskState !== "SCHEDULING" &&
ctx.taskId &&
ctx.sessionId;
return (
<>
<ChatMessage
comment={comment}
label="You"
className="bg-primary/10 text-foreground border-primary/30"
allMessages={ctx.allMessages}
onScrollToMessage={ctx.onScrollToMessage}
/>
{showStartButton && (
<TaskDescriptionStartButton taskId={ctx.taskId!} sessionId={ctx.sessionId!} />
)}
</>
);
}
return (
<ChatMessage
comment={comment}
label="Agent"
className="bg-muted/40 text-foreground border-border/60"
showRichBlocks={comment.type === "message" || comment.type === "content" || !comment.type}
allMessages={ctx.allMessages}
onScrollToMessage={ctx.onScrollToMessage}
/>
);
},
},
];
type MessageRendererProps = {
comment: Message;
isTaskDescription: boolean;
sessionState?: TaskSessionState;
taskState?: TaskState;
taskId?: string;
permissionsByToolCallId?: Map<string, Message>;
childrenByParentToolCallId?: Map<string, Message[]>;
worktreePath?: string;
sessionId?: string;
onOpenFile?: (path: string) => void;
allMessages?: Message[];
onScrollToMessage?: (messageId: string) => void;
};
export const MessageRenderer = memo(function MessageRenderer({
comment,
isTaskDescription,
sessionState,
taskState,
taskId,
permissionsByToolCallId,
childrenByParentToolCallId,
worktreePath,
sessionId,
onOpenFile,
allMessages,
onScrollToMessage,
}: MessageRendererProps) {
const ctx = {
isTaskDescription,
sessionState,
taskState,
taskId,
permissionsByToolCallId,
childrenByParentToolCallId,
worktreePath,
sessionId,
onOpenFile,
allMessages,
onScrollToMessage,
};
const adapter =
adapters.find((entry) => entry.matches(comment, ctx)) ?? adapters[adapters.length - 1];
return adapter.render(comment, ctx);
});