diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index 84855090fb..1076fe4714 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -24,6 +24,10 @@ import { ToolSubagentMessage } from "@/components/task/chat/messages/tool-subage 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; @@ -181,6 +185,14 @@ const adapters: MessageAdapter[] = [ ); }, }, + { + // 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) => , + }, { matches: (comment) => comment.type === "tool_call", render: (comment, ctx) => { diff --git a/apps/web/components/task/chat/messages/kandev-tool-message.test.tsx b/apps/web/components/task/chat/messages/kandev-tool-message.test.tsx new file mode 100644 index 0000000000..09def976ae --- /dev/null +++ b/apps/web/components/task/chat/messages/kandev-tool-message.test.tsx @@ -0,0 +1,275 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { KandevToolMessage, hasKandevRenderer } from "./kandev-tool-message"; +import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http"; + +// kandevToolCall constructs the kandev Message shape produced by the +// orchestrator for an MCP tool_call. The shape mirrors live production data +// observed on a real session: +// - `metadata.tool_name` — NOT set +// - `metadata.title` — raw tool name (`mcp__kandev__list_*_kandev`) +// - `comment.content` — same raw tool name +// - `normalized.generic.name` — the ACP adapter's *category* ("other"), +// NOT the tool name +// The matcher must therefore look at title/content, not generic.name — +// matching on the wrong field is exactly the bug that shipped initially. +function kandevToolCall(opts: { + toolName: string; + input?: Record; + resultJson?: unknown; + status?: "pending" | "running" | "complete" | "error"; +}): Message { + return { + id: "msg-1", + session_id: toSessionId("s1"), + task_id: toTaskId("t1"), + author_type: "agent", + content: opts.toolName, + type: "tool_call", + created_at: "2026-05-21T10:00:00Z", + metadata: { + tool_call_id: "tc-1", + title: opts.toolName, + status: opts.status ?? "complete", + normalized: { + kind: "generic", + generic: { + name: "other", + input: opts.input, + output: + opts.resultJson !== undefined + ? [{ type: "text", text: JSON.stringify(opts.resultJson) }] + : undefined, + }, + }, + }, + }; +} + +describe("hasKandevRenderer", () => { + it("matches a registered kandev tool", () => { + expect(hasKandevRenderer(kandevToolCall({ toolName: "mcp__kandev__list_tasks_kandev" }))).toBe( + true, + ); + }); + + it("does not match unrelated tools", () => { + expect(hasKandevRenderer(kandevToolCall({ toolName: "Edit" }))).toBe(false); + expect(hasKandevRenderer(kandevToolCall({ toolName: "mcp__github__list_issues" }))).toBe(false); + }); + + it("does not match kandev tools with no registered renderer", () => { + expect( + hasKandevRenderer(kandevToolCall({ toolName: "mcp__kandev__never_heard_of_it_kandev" })), + ).toBe(false); + }); +}); + +// State labels and titles reused across multiple tool-DTOs. Pulled to +// constants to satisfy the "no duplicate string" lint rule. +const COMPLETED = "COMPLETED"; +const FIX_LOGIN_BUG = "Fix login bug"; + +// The expandable row only renders its body when expanded. For tests that +// need to inspect the body, set status to "running" which auto-expands the +// row — the same trigger we rely on at runtime to keep an in-progress tool +// call visible to the user. + +describe("KandevToolMessage list renderers", () => { + it("renders the workflow-steps list with structured fields, not raw JSON", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Kandev: List Workflow Steps"); + expect(html).toContain("Backlog"); + expect(html).toContain("Doing"); + expect(html).toContain("2 steps"); + // The raw "\n" escape sequences from the broken JSON dump must not be + // present any more. + expect(html).not.toMatch(/\\n/); + expect(html).not.toContain('"steps":'); + }); + + it("renders list_tasks with task state badges", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Kandev: List Tasks"); + expect(html).toContain(FIX_LOGIN_BUG); + expect(html).toContain("Add dashboard"); + expect(html).toContain(COMPLETED); + expect(html).toContain("RUNNING"); + }); + + it("renders list_workspaces with the count and item names", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Kandev: List Workspaces"); + expect(html).toContain("2 workspaces"); + expect(html).toContain("Main"); + expect(html).toContain("Side project"); + }); +}); + +describe("KandevToolMessage task renderers", () => { + it("shows the title argument inline for create_task", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Kandev: Create Task"); + expect(html).toContain(FIX_LOGIN_BUG); + expect(html).toContain("CREATED"); + }); + + it("shows updated fields inline for update_task", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Kandev: Update Task"); + expect(html).toContain(`state=${COMPLETED}`); + }); +}); + +describe("KandevToolMessage document & question renderers", () => { + it("renders task plan markdown content for get_task_plan", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Kandev: Get Task Plan"); + expect(html).toContain("Migration plan"); + // Markdown is rendered through ReactMarkdown so the heading and bold + // mark up into proper HTML tags rather than raw text. + expect(html).toContain("bold"); + }); + + it("renders ask_user_question with the prompt and options", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Kandev: Ask User Question"); + expect(html).toContain("Which database should we use?"); + expect(html).toContain("Postgres"); + expect(html).toContain("SQLite"); + }); + + // Regression guard for the array-shape response branch in + // matchAnswerForQuestion. Positional arrays come from older backends or + // single-question calls; the renderer must match the answer by index. + it("renders ask_user_question with positional array responses", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Pick one"); + // Selected option gets the "default" Badge variant; unselected stays "outline". + expect(html).toMatch(/data-variant="default"[^>]*>A<\/span>/); + }); + + it("does not render anything when the renderer is missing", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toBe(""); + }); +}); diff --git a/apps/web/components/task/chat/messages/kandev-tool-message.tsx b/apps/web/components/task/chat/messages/kandev-tool-message.tsx new file mode 100644 index 0000000000..02cb86fdc5 --- /dev/null +++ b/apps/web/components/task/chat/messages/kandev-tool-message.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { memo } from "react"; +import type { Message } from "@/lib/types/http"; +import type { ToolCallMetadata } from "@/components/task/chat/types"; +import { extractKandevStem, extractMcpResult } from "./kandev/parse"; +import { getKandevRenderer } from "./kandev/registry"; + +type KandevToolMessageProps = { + comment: Message; +}; + +// kandevStemOf scans the several fields that may carry the raw MCP tool name +// and returns the first one that parses to a known kandev stem. The fields +// disagree in practice: +// - `metadata.tool_name` — not set by the orchestrator today (null). +// - `metadata.title` — the raw `mcp__kandev___kandev` string. +// - `comment.content` — same raw string, redundant with title. +// - `metadata.normalized.generic.name` — the ACP adapter's *category* +// (often `"other"`) rather than the tool name, so it cannot be matched on. +// We iterate candidates instead of picking a single "preferred" one because +// the live data showed `generic.name = "other"`, which would short-circuit +// any priority-based ordering on the wrong field. +function kandevStemOf(comment: Message): string | null { + const meta = comment.metadata as ToolCallMetadata | undefined; + const candidates: Array = [ + meta?.tool_name, + meta?.title, + comment.content || undefined, + meta?.normalized?.generic?.name, + ]; + for (const candidate of candidates) { + const stem = extractKandevStem(candidate); + if (stem) return stem; + } + return null; +} + +// hasKandevRenderer is the matcher used by the message dispatcher. It accepts +// any `tool_call` whose tool name is recognised as a Kandev MCP tool AND for +// which a per-tool renderer is registered. We require both because we still +// want unregistered Kandev tools to fall through to the generic ToolCallMessage +// (rather than rendering an empty row) until a dedicated renderer ships. +export function hasKandevRenderer(comment: Message): boolean { + if (comment.type !== "tool_call") return false; + return !!getKandevRenderer(kandevStemOf(comment)); +} + +// KandevToolMessage is the rendered entry point for every Kandev tool call. +// It parses the metadata once, looks up the per-tool renderer, and hands the +// renderer pre-parsed args + result. If the renderer lookup fails we render +// nothing rather than crashing; the matcher above guards against this in +// practice, but defensive nulling avoids a bad runtime error if the dispatcher +// rules drift out of sync with the registry. +export const KandevToolMessage = memo(function KandevToolMessage({ + comment, +}: KandevToolMessageProps) { + const meta = comment.metadata as ToolCallMetadata | undefined; + const renderer = getKandevRenderer(kandevStemOf(comment)); + if (!renderer) return null; + + // The ACP normalizer stores MCP tool args/result inside the Generic payload: + // `generic.input` holds the call args, `generic.output` the MCP result. We + // also accept `metadata.args` / `metadata.result` as fallbacks so the + // component still works for any code path that hasn't routed through the + // normalizer yet. + const generic = meta?.normalized?.generic; + const argsCandidate = (generic?.input as Record | undefined) ?? meta?.args; + const args = argsCandidate && typeof argsCandidate === "object" ? argsCandidate : undefined; + const rawResult = generic?.output ?? meta?.result; + const result = extractMcpResult(rawResult); + + // Each renderer is a stable function-pointer pulled from the static + // registry, so invoking it like a function (rather than via JSX) is safe + // and avoids the lint rule against "components created during render". + return renderer({ args, result, status: meta?.status }); +}); diff --git a/apps/web/components/task/chat/messages/kandev/ask-user-question-renderer.tsx b/apps/web/components/task/chat/messages/kandev/ask-user-question-renderer.tsx new file mode 100644 index 0000000000..af73c29faa --- /dev/null +++ b/apps/web/components/task/chat/messages/kandev/ask-user-question-renderer.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { IconHelpHexagon } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { + EmptyListNote, + KandevBody, + KandevRow, + KeyValueRow, + SummaryDot, + pluralCount, +} from "./shared"; +import { pickArray, pickString, shortId } from "./parse"; +import type { KandevRenderer } from "./types"; + +type QuestionOption = { label?: string; description?: string }; +type Question = { + id?: string; + prompt?: string; + options?: QuestionOption[]; +}; + +type AnswerEntry = { question_id?: string; selected?: string; custom_text?: string }; + +// QuestionBlock renders a single question with its options and (if available) +// the user's answer underlined in the body so a completed call is informative +// at a glance. +function QuestionBlock({ q, answer }: { q: Question; answer: AnswerEntry | undefined }) { + return ( +
+ {q.prompt &&
{q.prompt}
} + {q.options && q.options.length > 0 && ( +
+ {q.options.map((opt, i) => { + const isSelected = answer?.selected === opt.label; + return ( + + {opt.label ?? `option ${i + 1}`} + + ); + })} +
+ )} + {answer?.custom_text && ( + + {answer.custom_text} + + )} +
+ ); +} + +// matchAnswerForQuestion accepts both response shapes the backend may emit: +// keyed by question id (`{ q1: {...} }`) or as a positional list. The raw +// `responses` value is read directly rather than via `pickObject`, because +// the latter discards arrays and would silently lose list-shaped payloads. +function matchAnswerForQuestion( + responses: unknown, + question: Question, + index: number, +): AnswerEntry | undefined { + if (!responses || typeof responses !== "object") return undefined; + if (Array.isArray(responses)) return responses[index] as AnswerEntry | undefined; + if (question.id) { + const entry = (responses as Record)[question.id]; + if (entry) return entry as AnswerEntry; + } + return undefined; +} + +function readResponses(result: unknown): unknown { + if (!result || typeof result !== "object") return undefined; + return (result as Record).responses; +} + +export const AskUserQuestionRenderer: KandevRenderer = ({ args, result, status }) => { + const questions = pickArray(args, "questions") ?? []; + const context = pickString(args, "context"); + const responses = readResponses(result); + const pendingId = pickString(result, "pending_id"); + + // Build a short header summary: count of questions, plus the first prompt + // truncated so the row stays single-line. + const firstPrompt = questions[0]?.prompt; + const promptShort = firstPrompt ? firstPrompt.replace(/\s+/g, " ").trim() : undefined; + + return ( + + {pluralCount(questions.length, "question")} + {promptShort && ( + <> + + “{promptShort}” + + )} + + } + status={status} + hasExpandableContent={questions.length > 0 || !!context} + > + + {context && ( + + {context} + + )} + {questions.length === 0 ? ( + + ) : ( +
+ {questions.map((q, i) => ( + + ))} +
+ )} + {pendingId && status === "running" && ( +
+ Awaiting user response (pending_id={shortId(pendingId)}) +
+ )} +
+
+ ); +}; diff --git a/apps/web/components/task/chat/messages/kandev/document-renderers.tsx b/apps/web/components/task/chat/messages/kandev/document-renderers.tsx new file mode 100644 index 0000000000..260f687eb2 --- /dev/null +++ b/apps/web/components/task/chat/messages/kandev/document-renderers.tsx @@ -0,0 +1,370 @@ +"use client"; + +import { type ReactNode } from "react"; +import ReactMarkdown from "react-markdown"; +import { + IconChecklist, + IconFile, + IconFileText, + IconMessageCircle, + IconPencil, + IconPlus, + IconTrash, +} from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components"; +import { + EmptyListNote, + IdChip, + KandevBody, + KandevRow, + KeyValueRow, + SummaryDot, + pluralCount, +} from "./shared"; +import { pickArray, pickNumber, pickString } from "./parse"; +import type { KandevRenderer } from "./types"; + +// MarkdownBody renders task plan / document content. We pre-trim and use the +// shared markdown component set so heading sizes, code blocks, and mermaid +// rendering match the rest of the app. +function MarkdownBody({ content }: { content: string | undefined }) { + if (!content) return null; + return ( +
+ + {content} + +
+ ); +} + +// ContentBox is the bordered/scrollable container we use for any non-trivial +// markdown content. Capped height so a 5 000-line plan can't visually push +// the rest of the chat off-screen. +function ContentBox({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function summarizeContent(content: string | undefined): string { + if (!content) return "empty"; + const lines = content.split("\n").length; + const chars = content.length; + return `${lines} line${lines === 1 ? "" : "s"} · ${chars} chars`; +} + +// ---------- get_task_plan ---------- + +export const GetTaskPlanRenderer: KandevRenderer = ({ args, result, status }) => { + const taskId = pickString(args, "task_id"); + const content = pickString(result, "content"); + const title = pickString(result, "title"); + const hasPlan = !!content; + return ( + + {taskId && ( + <> + + + + )} + {hasPlan ? summarizeContent(content) : "no plan"} + + } + status={status} + hasExpandableContent={hasPlan} + > + + {title && {title}} + {hasPlan ? ( + + + + ) : ( + + )} + + + ); +}; + +// ---------- create_task_plan ---------- + +export const CreateTaskPlanRenderer: KandevRenderer = ({ args, result, status }) => { + const taskId = pickString(args, "task_id"); + const argContent = pickString(args, "content"); + const argTitle = pickString(args, "title"); + const resultId = pickString(result, "id"); + // Prefer the canonical result content when the call has finished — it + // reflects any backend normalisation. Fall back to the arg content while + // streaming so we don't leave the body blank. + const displayContent = pickString(result, "content") ?? argContent; + const displayTitle = pickString(result, "title") ?? argTitle; + return ( + + + {resultId && ( + <> + + + + )} + + } + status={status} + hasExpandableContent={!!displayContent} + > + + {displayTitle && {displayTitle}} + {displayContent && ( + + + + )} + + + ); +}; + +// ---------- update_task_plan ---------- + +export const UpdateTaskPlanRenderer: KandevRenderer = ({ args, result, status }) => { + const taskId = pickString(args, "task_id"); + const argContent = pickString(args, "content"); + const displayContent = pickString(result, "content") ?? argContent; + const displayTitle = pickString(result, "title") ?? pickString(args, "title"); + return ( + + {taskId && ( + <> + + + + )} + {summarizeContent(displayContent)} + + } + status={status} + hasExpandableContent={!!displayContent} + > + + {displayTitle && {displayTitle}} + {displayContent && ( + + + + )} + + + ); +}; + +// ---------- delete_task_plan ---------- + +export const DeleteTaskPlanRenderer: KandevRenderer = ({ args, status }) => { + const taskId = pickString(args, "task_id"); + return ( + } + status={status} + hasExpandableContent={false} + /> + ); +}; + +// ---------- get_task_document ---------- + +export const GetTaskDocumentRenderer: KandevRenderer = ({ args, result, status }) => { + const taskId = pickString(args, "task_id"); + const docKey = pickString(args, "document_key") ?? pickString(result, "key"); + const content = pickString(result, "content"); + const title = pickString(result, "title"); + const type = pickString(result, "type"); + const author = pickString(result, "author"); + return ( + + + {docKey && ( + <> + + {docKey} + + )} + + } + status={status} + hasExpandableContent={!!content} + > + +
+ {title && {title}} + {type && ( + + {type} + + )} + {author && by {author}} +
+ {content && ( + + + + )} +
+
+ ); +}; + +// ---------- write_task_document ---------- + +export const WriteTaskDocumentRenderer: KandevRenderer = ({ args, result, status }) => { + const taskId = pickString(args, "task_id"); + const docKey = pickString(args, "document_key") ?? pickString(result, "key"); + const argContent = pickString(args, "content"); + const displayContent = pickString(result, "content") ?? argContent; + const title = pickString(args, "title") ?? pickString(result, "title"); + const type = pickString(args, "type") ?? pickString(result, "type"); + return ( + + + {docKey && ( + <> + + {docKey} + + )} + + } + status={status} + hasExpandableContent={!!displayContent} + > + +
+ {title && {title}} + {type && ( + + {type} + + )} +
+ {displayContent && ( + + + + )} +
+
+ ); +}; + +// ---------- get_task_conversation ---------- + +type ConversationMessage = { + id?: string; + author_type?: string; + type?: string; + content?: string; + created_at?: string; +}; + +const MAX_INLINE_MESSAGES = 30; + +function ConversationMessageRow({ msg }: { msg: ConversationMessage }) { + const isUser = msg.author_type === "user"; + // Render the author label as a small uppercase tag rather than a coloured + // bubble — the chat is already inside a tool-call card, so a heavy bubble + // style would visually drown out the surrounding messages. + return ( +
+
+ {isUser ? "user" : (msg.author_type ?? "agent")} + {msg.type && msg.type !== "message" && ( + + {msg.type} + + )} +
+ {msg.content && ( +
{msg.content}
+ )} +
+ ); +} + +export const GetTaskConversationRenderer: KandevRenderer = ({ args, result, status }) => { + const taskId = pickString(args, "task_id"); + const sessionId = pickString(args, "session_id") ?? pickString(result, "session_id"); + const messages = pickArray(result, "messages") ?? []; + // The backend paginates: `total` is the absolute count, `messages.length` + // is just the current page. The "more not shown" footer must account for + // both the inline-cap *and* any server-side pagination, otherwise a + // capped page (total=200, messages=50) reads as if everything was visible. + const total = pickNumber(result, "total") ?? messages.length; + const visible = messages.slice(0, MAX_INLINE_MESSAGES); + const hiddenCount = Math.max(0, total - visible.length); + const truncated = hiddenCount > 0; + return ( + + {taskId && } + {sessionId && ( + <> + {taskId && } + + + )} + {(taskId || sessionId) && } + {pluralCount(total, "message")} + + } + status={status} + hasExpandableContent={messages.length > 0} + > + + {messages.length === 0 ? ( + + ) : ( +
+ {visible.map((m, i) => ( + + ))} + {truncated && ( +
+ + {hiddenCount} more not shown +
+ )} +
+ )} +
+
+ ); +}; diff --git a/apps/web/components/task/chat/messages/kandev/list-renderers.tsx b/apps/web/components/task/chat/messages/kandev/list-renderers.tsx new file mode 100644 index 0000000000..e7246fe705 --- /dev/null +++ b/apps/web/components/task/chat/messages/kandev/list-renderers.tsx @@ -0,0 +1,476 @@ +"use client"; + +import { + IconBriefcase, + IconColumns3, + IconList, + IconListCheck, + IconRobot, + IconServer, + IconFiles, + IconLink, +} from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import { + EmptyListNote, + IdChip, + KandevBody, + KandevRow, + ListItemRow, + SummaryDot, + TaskStateBadge, + pluralCount, +} from "./shared"; +import { pickArray, pickNumber, pickString } from "./parse"; +import type { KandevRenderer } from "./types"; + +// NamedListRow is the canonical row layout for entries with name + id + +// optional description (workspaces, workflows, executor profiles). The id +// sits next to the name as a subtle hint; the description occupies its own +// line in a quieter colour. +function NamedListRow({ + name, + id, + description, +}: { + name: string | undefined; + id?: string; + description?: string; +}) { + return ( +
+
+ {name ?? "(unnamed)"} + +
+ {description &&
{description}
} +
+ ); +} + +// ---------- list_workspaces ---------- + +type WorkspaceItem = { + id?: string; + name?: string; + description?: string; +}; + +export const ListWorkspacesRenderer: KandevRenderer = ({ result, status }) => { + const items = pickArray(result, "workspaces") ?? []; + const total = pickNumber(result, "total") ?? items.length; + return ( + 0} + > + + {items.length === 0 ? ( + + ) : ( + items.map((w, i) => ( + + )) + )} + + + ); +}; + +// ---------- list_workflows ---------- + +type WorkflowItem = { + id?: string; + name?: string; + description?: string; + workspace_id?: string; +}; + +export const ListWorkflowsRenderer: KandevRenderer = ({ args, result, status }) => { + const items = pickArray(result, "workflows") ?? []; + const total = pickNumber(result, "total") ?? items.length; + const workspaceId = pickString(args, "workspace_id"); + return ( + + {workspaceId && ( + <> + + + + )} + {pluralCount(total, "workflow")} + + } + status={status} + hasExpandableContent={items.length > 0} + > + + {items.length === 0 ? ( + + ) : ( + items.map((w, i) => ( + + )) + )} + + + ); +}; + +// ---------- list_workflow_steps ---------- + +type WorkflowStep = { + id?: string; + name?: string; + position?: number; + color?: string; + is_start_step?: boolean; + stage_type?: string; + workflow_id?: string; +}; + +// Map the backend's Tailwind colour name (e.g. "bg-neutral-400") into a +// small dot next to the step name. Falls back to a neutral fill for unknown +// values so a renamed colour upstream doesn't break the row layout. +function StepColorDot({ color }: { color: string | undefined }) { + const cls = color && color.startsWith("bg-") ? color : "bg-muted-foreground/40"; + return ; +} + +export const ListWorkflowStepsRenderer: KandevRenderer = ({ args, result, status }) => { + const items = pickArray(result, "steps") ?? []; + const total = pickNumber(result, "total") ?? items.length; + const sorted = [...items].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + const workflowId = pickString(args, "workflow_id"); + return ( + + {workflowId && ( + <> + + + + )} + {pluralCount(total, "step")} + + } + status={status} + hasExpandableContent={items.length > 0} + > + + {items.length === 0 ? ( + + ) : ( + sorted.map((step, i) => ( +
+ + {step.name ?? "(unnamed)"} + {step.is_start_step && ( + start + )} + {step.stage_type && step.stage_type !== "custom" && ( + {step.stage_type} + )} +
+ )) + )} +
+
+ ); +}; + +// ---------- list_tasks ---------- + +type TaskItem = { + id?: string; + title?: string; + state?: string; + workflow_step_id?: string; + position?: number; + description?: string; +}; + +export const ListTasksRenderer: KandevRenderer = ({ args, result, status }) => { + const items = pickArray(result, "tasks") ?? []; + const total = pickNumber(result, "total") ?? items.length; + const workflowId = pickString(args, "workflow_id"); + return ( + + {workflowId && ( + <> + + + + )} + {pluralCount(total, "task")} + + } + status={status} + hasExpandableContent={items.length > 0} + > + + {items.length === 0 ? ( + + ) : ( + items.map((t, i) => ( +
+ + {t.title ?? "(untitled)"} + +
+ )) + )} +
+
+ ); +}; + +// ---------- list_related_tasks ---------- + +type RelatedTaskItem = { + id?: string; + title?: string; + state?: string; + workflow_step_id?: string; +}; + +const RELATED_GROUPS: Array<{ key: string; label: string }> = [ + { key: "parents", label: "Parents" }, + { key: "children", label: "Children" }, + { key: "siblings", label: "Siblings" }, + { key: "blockers", label: "Blockers" }, + { key: "blocked_by", label: "Blocked by" }, +]; + +function RelatedGroup({ label, items }: { label: string; items: RelatedTaskItem[] }) { + if (items.length === 0) return null; + return ( +
+
{label}
+ {items.map((t, i) => ( +
+ + {t.title ?? "(untitled)"} + +
+ ))} +
+ ); +} + +export const ListRelatedTasksRenderer: KandevRenderer = ({ args, result, status }) => { + const groups = RELATED_GROUPS.map((g) => ({ + ...g, + items: pickArray(result, g.key) ?? [], + })); + const total = groups.reduce((sum, g) => sum + g.items.length, 0); + const taskId = pickString(args, "task_id"); + return ( + + {taskId && ( + <> + + + + )} + {pluralCount(total, "related task")} + + } + status={status} + hasExpandableContent={total > 0} + > + + {total === 0 ? ( + + ) : ( + groups.map((g) => ) + )} + + + ); +}; + +// ---------- list_agents ---------- + +type AgentProfile = { id?: string; name?: string; model?: string }; +type AgentItem = { + id?: string; + name?: string; + supports_mcp?: boolean; + profiles?: AgentProfile[]; +}; + +export const ListAgentsRenderer: KandevRenderer = ({ result, status }) => { + const items = pickArray(result, "agents") ?? []; + const total = pickNumber(result, "total") ?? items.length; + return ( + 0} + > + + {items.length === 0 ? ( + + ) : ( + items.map((a, i) => ( + +
{a.name ?? a.id ?? "(unnamed)"}
+ {a.profiles && a.profiles.length > 0 && ( +
+ {a.profiles + .map((p) => (p.model ? `${p.name ?? p.id} (${p.model})` : (p.name ?? p.id))) + .filter(Boolean) + .join(", ")} +
+ )} +
+ )) + )} +
+
+ ); +}; + +// ---------- list_executor_profiles ---------- + +type ExecutorProfileItem = { + id?: string; + name?: string; + executor_id?: string; + mcp_policy?: string; +}; + +export const ListExecutorProfilesRenderer: KandevRenderer = ({ args, result, status }) => { + const items = pickArray(result, "profiles") ?? []; + const total = pickNumber(result, "total") ?? items.length; + const executorId = pickString(args, "executor_id"); + return ( + + {executorId && ( + <> + {executorId} + + + )} + {pluralCount(total, "profile")} + + } + status={status} + hasExpandableContent={items.length > 0} + > + + {items.length === 0 ? ( + + ) : ( + items.map((p, i) => ( +
+ {p.name ?? "(unnamed)"} + {p.mcp_policy && ( + mcp: {p.mcp_policy} + )} + +
+ )) + )} +
+
+ ); +}; + +// ---------- list_task_documents ---------- + +type DocumentItem = { + key?: string; + title?: string; + type?: string; + author?: string; + size?: number; +}; + +export const ListTaskDocumentsRenderer: KandevRenderer = ({ args, result, status }) => { + const items = pickArray(result, "documents") ?? []; + const total = pickNumber(result, "total") ?? items.length; + const taskId = pickString(args, "task_id") ?? pickString(result, "task_id"); + return ( + + {taskId && ( + <> + + + + )} + {pluralCount(total, "document")} + + } + status={status} + hasExpandableContent={items.length > 0} + > + + {items.length === 0 ? ( + + ) : ( + items.map((d, i) => ( +
+ {d.title ?? d.key ?? "(untitled)"} + {d.key && d.title && ( + {d.key} + )} + {d.type && {d.type}} + {d.author && ( + by {d.author} + )} +
+ )) + )} +
+
+ ); +}; diff --git a/apps/web/components/task/chat/messages/kandev/parse.test.ts b/apps/web/components/task/chat/messages/kandev/parse.test.ts new file mode 100644 index 0000000000..b2c4642b38 --- /dev/null +++ b/apps/web/components/task/chat/messages/kandev/parse.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { extractKandevStem, extractMcpResult, shortId } from "./parse"; + +describe("extractKandevStem", () => { + it("strips the mcp__kandev__ namespace and the _kandev suffix", () => { + expect(extractKandevStem("mcp__kandev__list_tasks_kandev")).toBe("list_tasks"); + }); + + it("handles the codex-style kandev/ prefix", () => { + expect(extractKandevStem("kandev/list_tasks_kandev")).toBe("list_tasks"); + }); + + it("handles a bare suffix-only name", () => { + expect(extractKandevStem("create_task_kandev")).toBe("create_task"); + }); + + it("returns null for non-kandev tools", () => { + expect(extractKandevStem("mcp__github__list_issues")).toBeNull(); + expect(extractKandevStem("Edit")).toBeNull(); + expect(extractKandevStem("")).toBeNull(); + expect(extractKandevStem(undefined)).toBeNull(); + }); + + it("returns null when the suffix is bare (no stem)", () => { + expect(extractKandevStem("_kandev")).toBeNull(); + }); +}); + +describe("extractMcpResult", () => { + it("parses a single MCP content block", () => { + const blocks = [{ type: "text", text: '{"steps": [{"name": "Backlog"}]}' }]; + expect(extractMcpResult(blocks)).toEqual({ steps: [{ name: "Backlog" }] }); + }); + + it("joins multiple text blocks before JSON parsing", () => { + const blocks = [ + { type: "text", text: '{"a":' }, + { type: "text", text: "1}" }, + ]; + expect(extractMcpResult(blocks)).toEqual({ a: 1 }); + }); + + it("returns the raw string if blocks contain non-JSON text", () => { + const blocks = [{ type: "text", text: "hello world" }]; + expect(extractMcpResult(blocks)).toBe("hello world"); + }); + + it("unwraps a string containing JSON", () => { + expect(extractMcpResult('{"foo":"bar"}')).toEqual({ foo: "bar" }); + }); + + it("returns the raw string for non-JSON strings", () => { + expect(extractMcpResult("not json")).toBe("not json"); + }); + + it("returns null for empty/missing values", () => { + expect(extractMcpResult(undefined)).toBeNull(); + expect(extractMcpResult(null)).toBeNull(); + expect(extractMcpResult("")).toBeNull(); + expect(extractMcpResult(" ")).toBeNull(); + }); + + it("unwraps a CallToolResult-style object with content[]", () => { + const wrapped = { content: [{ type: "text", text: '{"ok":true}' }] }; + expect(extractMcpResult(wrapped)).toEqual({ ok: true }); + }); + + it("returns plain objects untouched", () => { + expect(extractMcpResult({ foo: 1 })).toEqual({ foo: 1 }); + }); +}); + +describe("shortId", () => { + it("truncates long uuids with an ellipsis", () => { + expect(shortId("4aad62c5-e549-495a-888b-14feecc28334")).toBe("4aad62c5…"); + }); + + it("returns short ids unchanged", () => { + expect(shortId("abc")).toBe("abc"); + expect(shortId("")).toBe(""); + expect(shortId(undefined)).toBe(""); + }); +}); diff --git a/apps/web/components/task/chat/messages/kandev/parse.ts b/apps/web/components/task/chat/messages/kandev/parse.ts new file mode 100644 index 0000000000..2001dfa1f8 --- /dev/null +++ b/apps/web/components/task/chat/messages/kandev/parse.ts @@ -0,0 +1,105 @@ +// Parsing helpers for Kandev MCP tool calls. +// +// Tool names arrive prefixed by the agent runtime: +// - Claude Code: `mcp__kandev___kandev` +// - Codex: `kandev/_kandev` +// - Bare: `_kandev` +// +// `extractKandevStem` strips the namespace and the trailing `_kandev` suffix so +// the dispatcher can match on the short stem (`list_tasks`, `create_task`, …). +// +// `extractMcpResult` unwraps the MCP CallToolResult shape that lands in +// `metadata.normalized.generic.output` (or `metadata.result`). The content is +// usually an array of `{type, text}` content blocks where `text` is itself a +// JSON-encoded string; we parse that inner JSON so the renderers see plain JS. + +const NAMESPACE_SEP = /\/|__/; +const KANDEV_SUFFIX = "_kandev"; + +export function extractKandevStem(toolName: string | undefined): string | null { + if (!toolName) return null; + const tail = toolName.trim().split(NAMESPACE_SEP).pop() ?? ""; + if (!tail.endsWith(KANDEV_SUFFIX)) return null; + const stem = tail.slice(0, -KANDEV_SUFFIX.length); + return stem.length > 0 ? stem : null; +} + +export function isKandevTool(toolName: string | undefined): boolean { + return extractKandevStem(toolName) !== null; +} + +type ContentBlock = { type?: string; text?: string }; + +function tryParseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function unwrapContentBlocks(blocks: unknown[]): unknown { + const textParts: string[] = []; + for (const block of blocks) { + if (!block || typeof block !== "object") continue; + const b = block as ContentBlock; + if (typeof b.text === "string") textParts.push(b.text); + } + if (textParts.length === 0) return blocks; + const joined = textParts.join(""); + return tryParseJson(joined); +} + +// extractMcpResult walks the various shapes the ACP/MCP transport can emit and +// returns the structured payload. Returns null if the value is empty/missing. +export function extractMcpResult(value: unknown): unknown { + if (value === null || value === undefined) return null; + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return null; + return tryParseJson(trimmed); + } + if (Array.isArray(value)) { + return unwrapContentBlocks(value); + } + if (typeof value === "object") { + const obj = value as { content?: unknown; output?: unknown }; + if (Array.isArray(obj.content)) return unwrapContentBlocks(obj.content); + if (typeof obj.output === "string") return tryParseJson(obj.output); + return value; + } + return value; +} + +export function pickString(obj: unknown, key: string): string | undefined { + if (!obj || typeof obj !== "object") return undefined; + const v = (obj as Record)[key]; + return typeof v === "string" ? v : undefined; +} + +export function pickNumber(obj: unknown, key: string): number | undefined { + if (!obj || typeof obj !== "object") return undefined; + const v = (obj as Record)[key]; + return typeof v === "number" ? v : undefined; +} + +export function pickArray(obj: unknown, key: string): T[] | undefined { + if (!obj || typeof obj !== "object") return undefined; + const v = (obj as Record)[key]; + return Array.isArray(v) ? (v as T[]) : undefined; +} + +export function pickObject(obj: unknown, key: string): Record | undefined { + if (!obj || typeof obj !== "object") return undefined; + const v = (obj as Record)[key]; + if (!v || typeof v !== "object" || Array.isArray(v)) return undefined; + return v as Record; +} + +// Shorten a UUID-like identifier for inline display (e.g. "abc12345…"). +const SHORT_ID_LEN = 8; +export function shortId(id: string | undefined): string { + if (!id) return ""; + if (id.length <= SHORT_ID_LEN + 1) return id; + return `${id.slice(0, SHORT_ID_LEN)}…`; +} diff --git a/apps/web/components/task/chat/messages/kandev/registry.ts b/apps/web/components/task/chat/messages/kandev/registry.ts new file mode 100644 index 0000000000..43939d7004 --- /dev/null +++ b/apps/web/components/task/chat/messages/kandev/registry.ts @@ -0,0 +1,62 @@ +// Tool-stem → renderer registry. Keep this file pure (no JSX) so it can be +// imported from the matcher in `message-renderer.tsx` without dragging the +// React component tree along. + +import { + ListAgentsRenderer, + ListExecutorProfilesRenderer, + ListRelatedTasksRenderer, + ListTaskDocumentsRenderer, + ListTasksRenderer, + ListWorkflowStepsRenderer, + ListWorkflowsRenderer, + ListWorkspacesRenderer, +} from "./list-renderers"; +import { + CreateTaskRenderer, + MessageTaskRenderer, + MoveTaskRenderer, + UpdateTaskRenderer, +} from "./task-renderers"; +import { + CreateTaskPlanRenderer, + DeleteTaskPlanRenderer, + GetTaskConversationRenderer, + GetTaskDocumentRenderer, + GetTaskPlanRenderer, + UpdateTaskPlanRenderer, + WriteTaskDocumentRenderer, +} from "./document-renderers"; +import { AskUserQuestionRenderer } from "./ask-user-question-renderer"; +import type { KandevRenderer } from "./types"; + +export const KANDEV_RENDERERS: Record = { + list_workspaces: ListWorkspacesRenderer, + list_workflows: ListWorkflowsRenderer, + list_workflow_steps: ListWorkflowStepsRenderer, + list_tasks: ListTasksRenderer, + list_related_tasks: ListRelatedTasksRenderer, + list_agents: ListAgentsRenderer, + list_executor_profiles: ListExecutorProfilesRenderer, + list_task_documents: ListTaskDocumentsRenderer, + + create_task: CreateTaskRenderer, + update_task: UpdateTaskRenderer, + move_task: MoveTaskRenderer, + message_task: MessageTaskRenderer, + + get_task_plan: GetTaskPlanRenderer, + create_task_plan: CreateTaskPlanRenderer, + update_task_plan: UpdateTaskPlanRenderer, + delete_task_plan: DeleteTaskPlanRenderer, + get_task_document: GetTaskDocumentRenderer, + write_task_document: WriteTaskDocumentRenderer, + get_task_conversation: GetTaskConversationRenderer, + + ask_user_question: AskUserQuestionRenderer, +}; + +export function getKandevRenderer(stem: string | null): KandevRenderer | null { + if (!stem) return null; + return KANDEV_RENDERERS[stem] ?? null; +} diff --git a/apps/web/components/task/chat/messages/kandev/shared.tsx b/apps/web/components/task/chat/messages/kandev/shared.tsx new file mode 100644 index 0000000000..665613f3c5 --- /dev/null +++ b/apps/web/components/task/chat/messages/kandev/shared.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { type ReactNode } from "react"; +import { Badge } from "@kandev/ui/badge"; +import { IconCheck, IconX, type IconProps, type Icon as TablerIcon } from "@tabler/icons-react"; +import { GridSpinner } from "@/components/grid-spinner"; +import { cn } from "@/lib/utils"; +import { ExpandableRow } from "../expandable-row"; +import { useExpandState } from "../use-expand-state"; +import { shortId } from "./parse"; + +export type KandevStatus = "pending" | "running" | "complete" | "error" | undefined; + +export function KandevStatusIcon({ status }: { status: KandevStatus }) { + if (status === "complete") return ; + if (status === "error") return ; + if (status === "running") return ; + return null; +} + +type KandevRowProps = { + Icon: TablerIcon | ((p: IconProps) => ReactNode); + title: string; + summary?: ReactNode; + status: KandevStatus; + hasExpandableContent: boolean; + children?: ReactNode; +}; + +// KandevRow is the consistent header row for every Kandev tool. Title is the +// "Kandev: …" string, summary is an inline description of args / result count, +// and children are the expanded body. Auto-expands while the tool is running, +// matching the pattern used by ToolExecuteMessage. +export function KandevRow({ + Icon, + title, + summary, + status, + hasExpandableContent, + children, +}: KandevRowProps) { + const autoExpanded = status === "running"; + const { isExpanded, handleToggle } = useExpandState(status, autoExpanded); + const isSuccess = status === "complete"; + + return ( + } + header={ +
+ {title} + {summary && ( + {summary} + )} + {!isSuccess && ( + + + + )} +
+ } + hasExpandableContent={hasExpandableContent} + isExpanded={isExpanded} + onToggle={handleToggle} + > + {children} +
+ ); +} + +// KandevBody is the bordered container used by all expanded sections so they +// match the look of ToolCallExpandedContent / ToolExecuteContent. +export function KandevBody({ children }: { children: ReactNode }) { + return
{children}
; +} + +// SummaryDot is a separator we use to chain short summary fragments in the +// header (`workflow=abc · 3 tasks`). The space-flanked middle-dot keeps the +// line compact even on narrow viewports. +export function SummaryDot() { + return ·; +} + +// IdChip displays a UUID-style id in a very subtle inline form, with the +// full id available on hover. No `label=` prefix — surrounding context +// (column position, neighbouring text) carries the meaning, and the prefix +// was making rows visually noisy. +export function IdChip({ id }: { id: string | undefined }) { + if (!id) return null; + return ( + + {shortId(id)} + + ); +} + +// KeyValueRow renders a single labelled field inside the expanded body. Used +// by per-tool renderers to format arg/result fields without rebuilding the +// same flex/gap markup over and over. +export function KeyValueRow({ + label, + children, + mono, +}: { + label: string; + children: ReactNode; + mono?: boolean; +}) { + return ( +
+ {label} + + {children} + +
+ ); +} + +// TaskStateBadge maps backend task states to coloured shadcn badges. Unknown +// states still render — they fall through to the outline variant so a new +// state added on the backend doesn't disappear from the UI before the +// frontend is updated. +const TASK_STATE_VARIANTS: Record< + string, + { variant: "default" | "secondary" | "outline" | "destructive"; className?: string } +> = { + CREATED: { variant: "outline" }, + SCHEDULING: { variant: "secondary" }, + RUNNING: { variant: "default" }, + WAITING_FOR_INPUT: { variant: "secondary" }, + PAUSED: { variant: "secondary" }, + COMPLETED: { variant: "default", className: "bg-green-600 hover:bg-green-600" }, + FAILED: { variant: "destructive" }, + CANCELLED: { variant: "outline" }, + ARCHIVED: { variant: "outline" }, +}; + +export function TaskStateBadge({ state }: { state: string | undefined }) { + if (!state) return null; + const cfg = TASK_STATE_VARIANTS[state] ?? { variant: "outline" as const }; + return ( + + {state} + + ); +} + +// EmptyListNote is what we render when a list response comes back with 0 +// items. Plain "No results" rendered inside the body rather than the header so +// the header stays consistent across all states. +export function EmptyListNote({ noun }: { noun: string }) { + return
No {noun} found.
; +} + +// ListItemRow is one row in a list-style result. Plain layout — no border, +// no background — so a long list reads as a clean column rather than a +// stack of nested cards. +export function ListItemRow({ children }: { children: ReactNode }) { + return
{children}
; +} + +// CountBadge formats `N items` / `1 item` for the header summary. Pure helper +// kept here so all renderers pluralise the same way. +export function pluralCount(n: number, noun: string, nounPlural?: string): string { + if (n === 1) return `1 ${noun}`; + return `${n} ${nounPlural ?? `${noun}s`}`; +} diff --git a/apps/web/components/task/chat/messages/kandev/task-renderers.tsx b/apps/web/components/task/chat/messages/kandev/task-renderers.tsx new file mode 100644 index 0000000000..cb79e82756 --- /dev/null +++ b/apps/web/components/task/chat/messages/kandev/task-renderers.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { IconArrowsExchange, IconMessage2, IconPencil, IconPlus } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { IdChip, KandevBody, KandevRow, KeyValueRow, SummaryDot, TaskStateBadge } from "./shared"; +import { pickArray, pickString } from "./parse"; +import type { KandevRenderer } from "./types"; + +type Repository = { + repository_id?: string; + local_path?: string; + github_url?: string; + base_branch?: string; +}; + +function RepoChips({ repos }: { repos: Repository[] }) { + if (repos.length === 0) return null; + return ( +
+ {repos.map((r, i) => ( + + {r.local_path ?? r.github_url ?? r.repository_id} + {r.base_branch ? ` @ ${r.base_branch}` : ""} + + ))} +
+ ); +} + +// TaskDTOBody renders the common task fields that come back from create / +// update / move endpoints. Keeps the per-tool renderers focused on header / +// summary differences only. +function TaskDTOBody({ task }: { task: Record | undefined }) { + if (!task) return null; + const title = pickString(task, "title"); + const description = pickString(task, "description"); + const state = pickString(task, "state"); + const id = pickString(task, "id"); + const workflowId = pickString(task, "workflow_id"); + const stepId = pickString(task, "workflow_step_id"); + const repos = pickArray(task, "repositories") ?? []; + return ( + <> +
+ + {title && {title}} + +
+ {description && ( +
+ {description} +
+ )} +
+ {workflowId && } + {stepId && } +
+ + + ); +} + +// ---------- create_task ---------- + +export const CreateTaskRenderer: KandevRenderer = ({ args, result, status }) => { + const title = pickString(args, "title"); + const task = + result && typeof result === "object" ? (result as Record) : undefined; + const createdId = pickString(task, "id"); + return ( + + {title && “{title}”} + {createdId && ( + <> + + + + )} + + } + status={status} + hasExpandableContent={!!task} + > + + + + + ); +}; + +// ---------- update_task ---------- + +export const UpdateTaskRenderer: KandevRenderer = ({ args, result, status }) => { + const taskId = pickString(args, "task_id"); + const newTitle = pickString(args, "title"); + const newState = pickString(args, "state"); + const newDescription = pickString(args, "description"); + const task = + result && typeof result === "object" ? (result as Record) : undefined; + + // Build a compact list of which fields were touched on this call. Showing + // just the field names (or values for state) keeps the header informative + // even when the title is long. + const changes: string[] = []; + if (newTitle !== undefined) changes.push("title"); + if (newDescription !== undefined) changes.push("description"); + if (newState !== undefined) changes.push(`state=${newState}`); + + return ( + + + {changes.length > 0 && ( + <> + + {changes.join(", ")} + + )} + + } + status={status} + hasExpandableContent={!!task || changes.length > 0} + > + + {newTitle !== undefined && {newTitle}} + {newState !== undefined && ( + + + + )} + {newDescription !== undefined && ( + + {newDescription} + + )} + {task && } + + + ); +}; + +// ---------- move_task ---------- + +export const MoveTaskRenderer: KandevRenderer = ({ args, result, status }) => { + const taskId = pickString(args, "task_id"); + const stepId = pickString(args, "workflow_step_id"); + const workflowId = pickString(args, "workflow_id"); + const prompt = pickString(args, "prompt"); + const task = + result && typeof result === "object" ? (result as Record) : undefined; + return ( + + + {taskId && stepId && } + + + } + status={status} + hasExpandableContent={!!task || !!prompt || !!workflowId || !!stepId} + > + + {workflowId && ( + + {workflowId} + + )} + {stepId && ( + + {stepId} + + )} + {prompt && ( + + {prompt} + + )} + {task && } + + + ); +}; + +// ---------- message_task ---------- + +export const MessageTaskRenderer: KandevRenderer = ({ args, result, status }) => { + const taskId = pickString(args, "task_id"); + const prompt = pickString(args, "prompt"); + // Truncate the prompt to a single short fragment for the header so a + // multi-line message still renders in a single chat row. + const promptShort = prompt ? prompt.replace(/\s+/g, " ").trim() : undefined; + // message_task can return either {success: true} or a TaskSessionDTO. Treat + // anything object-shaped with an `id` as the session DTO. + const sessionId = pickString(result, "id"); + const session = sessionId ? (result as Record) : undefined; + return ( + + + {promptShort && ( + <> + + “{promptShort}” + + )} + + } + status={status} + hasExpandableContent={!!prompt} + > + + {prompt && ( + + {prompt} + + )} + {session && ( + + + + )} + + + ); +}; diff --git a/apps/web/components/task/chat/messages/kandev/types.ts b/apps/web/components/task/chat/messages/kandev/types.ts new file mode 100644 index 0000000000..4ff9f04959 --- /dev/null +++ b/apps/web/components/task/chat/messages/kandev/types.ts @@ -0,0 +1,17 @@ +// Shared types used by Kandev tool renderers. +// +// `RendererProps` is what the dispatcher passes to every per-tool renderer. +// Renderers receive the parsed args and result (already unwrapped from the MCP +// envelope) plus the tool status, and are responsible for returning a fully +// composed `` element. + +import type { ReactElement } from "react"; +import type { KandevStatus } from "./shared"; + +export type KandevRendererProps = { + args: Record | undefined; + result: unknown; + status: KandevStatus; +}; + +export type KandevRenderer = (props: KandevRendererProps) => ReactElement;