feat(web): structured renderers for Kandev MCP tool calls (#987)

* feat(web): structured renderers for Kandev MCP tool calls

Replaces the raw-JSON dump in the chat for every Kandev MCP tool with a
per-tool structured view. Adds 20 renderers — list (workspaces, workflows,
workflow-steps, tasks, related, agents, executor-profiles, task-documents),
task actions (create/update/move/message), plan + document (get/create/update/
delete plan, get/write document, conversation), and ask_user_question.

Dispatcher in message-renderer.tsx scans tool name on metadata.title,
comment.content and normalized.generic.name (the ACP normalizer's "name"
field is the *category*, often "other", not the tool name).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): address review feedback on kandev tool renderers

- ask_user_question: accept both keyed-object and positional-array
  responses shapes (replaces pickObject which silently discarded arrays).
- get_task_conversation: derive the "more not shown" footer from the
  backend's absolute total so paginated responses don't hide pages, and
  guard the leading SummaryDot when both taskId and sessionId are absent.
- IdChip: drop unused label prop and the stale label="..." callsites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): consistency follow-ups on kandev renderers

- Guard the leading SummaryDot in GetTaskPlanRenderer and
  UpdateTaskPlanRenderer the same way GetTaskConversationRenderer does.
- ListTaskDocumentsRenderer reads `total` from the result like the other
  list renderers, so a paginated document list doesn't understate.
- Add a regression test for the positional-array `responses` branch in
  matchAnswerForQuestion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): guard MoveTaskRenderer SummaryDot

Same orphan-dot fix as the other renderers — only render the dot when
both flanking IdChips are present.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): final review nits on kandev renderers

- ask_user_question: use shortId() for the pending_id hint instead of
  hand-rolled slice (handles short ids correctly).
- list-renderers: add a stable index fallback to every map() key when
  id/name/title are all absent, so React reconciliation stays stable on
  malformed payloads.
- MoveTaskRenderer: extend hasExpandableContent to cover workflow/step
  rows so they remain accessible without task/prompt data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-05-21 04:14:42 +01:00
committed by GitHub
parent 3d860290da
commit 3ff3a53c75
12 changed files with 2019 additions and 0 deletions
@@ -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) => <KandevToolMessage comment={comment} />,
},
{
matches: (comment) => comment.type === "tool_call",
render: (comment, ctx) => {
@@ -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<string, unknown>;
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(
<KandevToolMessage
comment={kandevToolCall({
status: "running",
toolName: "mcp__kandev__list_workflow_steps_kandev",
input: { workflow_id: "f058586c-8a32-474b-ac02-eef47c938b41" },
resultJson: {
steps: [
{ id: "4aad62c5", name: "Backlog", position: 0, color: "bg-neutral-400" },
{
id: "step-2",
name: "Doing",
position: 1,
color: "bg-blue-500",
is_start_step: true,
},
],
total: 2,
},
})}
/>,
);
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(
<KandevToolMessage
comment={kandevToolCall({
status: "running",
toolName: "list_tasks_kandev",
input: { workflow_id: "wf-1" },
resultJson: {
tasks: [
{ id: "t1", title: FIX_LOGIN_BUG, state: COMPLETED },
{ id: "t2", title: "Add dashboard", state: "RUNNING" },
],
total: 2,
},
})}
/>,
);
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(
<KandevToolMessage
comment={kandevToolCall({
status: "running",
toolName: "mcp__kandev__list_workspaces_kandev",
resultJson: {
workspaces: [
{ id: "w1", name: "Main" },
{ id: "w2", name: "Side project" },
],
total: 2,
},
})}
/>,
);
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(
<KandevToolMessage
comment={kandevToolCall({
status: "running",
toolName: "kandev/create_task_kandev",
input: { title: FIX_LOGIN_BUG, description: "..." },
resultJson: { id: "new-id", title: FIX_LOGIN_BUG, state: "CREATED" },
})}
/>,
);
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(
<KandevToolMessage
comment={kandevToolCall({
toolName: "mcp__kandev__update_task_kandev",
input: { task_id: "t1", state: COMPLETED },
resultJson: { id: "t1", title: "Old title", state: COMPLETED },
})}
/>,
);
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(
<KandevToolMessage
comment={kandevToolCall({
status: "running",
toolName: "mcp__kandev__get_task_plan_kandev",
input: { task_id: "t1" },
resultJson: {
id: "p1",
task_id: "t1",
title: "Migration plan",
content: "# Heading\n\nSome **bold** content.",
},
})}
/>,
);
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("<h1");
expect(html).toContain("<strong>bold</strong>");
});
it("renders ask_user_question with the prompt and options", () => {
const html = renderToStaticMarkup(
<KandevToolMessage
comment={kandevToolCall({
status: "running",
toolName: "mcp__kandev__ask_user_question_kandev",
input: {
questions: [
{
id: "q1",
prompt: "Which database should we use?",
options: [{ label: "Postgres" }, { label: "SQLite" }],
},
],
},
resultJson: {
pending_id: "pend-1",
responses: { q1: { question_id: "q1", selected: "Postgres" } },
},
})}
/>,
);
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(
<KandevToolMessage
comment={kandevToolCall({
status: "running",
toolName: "mcp__kandev__ask_user_question_kandev",
input: {
questions: [
{ id: "q1", prompt: "Pick one", options: [{ label: "A" }, { label: "B" }] },
],
},
resultJson: { responses: [{ question_id: "q1", selected: "A" }] },
})}
/>,
);
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(
<KandevToolMessage
comment={kandevToolCall({ toolName: "mcp__kandev__unknown_tool_kandev" })}
/>,
);
expect(html).toBe("");
});
});
@@ -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__<tool>_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<string | undefined> = [
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<string, unknown> | 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 });
});
@@ -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 (
<div className="space-y-1.5">
{q.prompt && <div className="text-xs text-foreground whitespace-pre-wrap">{q.prompt}</div>}
{q.options && q.options.length > 0 && (
<div className="flex flex-wrap gap-1">
{q.options.map((opt, i) => {
const isSelected = answer?.selected === opt.label;
return (
<Badge
key={`${opt.label ?? i}`}
variant={isSelected ? "default" : "outline"}
className="text-[10px]"
title={opt.description}
>
{opt.label ?? `option ${i + 1}`}
</Badge>
);
})}
</div>
)}
{answer?.custom_text && (
<KeyValueRow label="answer">
<span className="whitespace-pre-wrap">{answer.custom_text}</span>
</KeyValueRow>
)}
</div>
);
}
// 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<string, unknown>)[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<string, unknown>).responses;
}
export const AskUserQuestionRenderer: KandevRenderer = ({ args, result, status }) => {
const questions = pickArray<Question>(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 (
<KandevRow
Icon={IconHelpHexagon}
title="Kandev: Ask User Question"
summary={
<span className="inline-flex items-center gap-1.5 min-w-0">
<span>{pluralCount(questions.length, "question")}</span>
{promptShort && (
<>
<SummaryDot />
<span className="truncate max-w-[50ch]">&ldquo;{promptShort}&rdquo;</span>
</>
)}
</span>
}
status={status}
hasExpandableContent={questions.length > 0 || !!context}
>
<KandevBody>
{context && (
<KeyValueRow label="context">
<span className="whitespace-pre-wrap">{context}</span>
</KeyValueRow>
)}
{questions.length === 0 ? (
<EmptyListNote noun="questions" />
) : (
<div className="space-y-3">
{questions.map((q, i) => (
<QuestionBlock
key={q.id ?? i}
q={q}
answer={matchAnswerForQuestion(responses, q, i)}
/>
))}
</div>
)}
{pendingId && status === "running" && (
<div className="text-[10px] italic text-muted-foreground/70">
Awaiting user response (pending_id={shortId(pendingId)})
</div>
)}
</KandevBody>
</KandevRow>
);
};
@@ -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 (
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
{content}
</ReactMarkdown>
</div>
);
}
// 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 (
<div className="border border-border/40 rounded p-2 bg-muted/20 max-h-[400px] overflow-y-auto">
{children}
</div>
);
}
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 (
<KandevRow
Icon={IconChecklist}
title="Kandev: Get Task Plan"
summary={
<span className="inline-flex items-center gap-1.5">
{taskId && (
<>
<IdChip id={taskId} />
<SummaryDot />
</>
)}
<span>{hasPlan ? summarizeContent(content) : "no plan"}</span>
</span>
}
status={status}
hasExpandableContent={hasPlan}
>
<KandevBody>
{title && <KeyValueRow label="title">{title}</KeyValueRow>}
{hasPlan ? (
<ContentBox>
<MarkdownBody content={content} />
</ContentBox>
) : (
<EmptyListNote noun="plan" />
)}
</KandevBody>
</KandevRow>
);
};
// ---------- 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 (
<KandevRow
Icon={IconPlus}
title="Kandev: Create Task Plan"
summary={
<span className="inline-flex items-center gap-1.5">
<IdChip id={taskId} />
{resultId && (
<>
<SummaryDot />
<IdChip id={resultId} />
</>
)}
</span>
}
status={status}
hasExpandableContent={!!displayContent}
>
<KandevBody>
{displayTitle && <KeyValueRow label="title">{displayTitle}</KeyValueRow>}
{displayContent && (
<ContentBox>
<MarkdownBody content={displayContent} />
</ContentBox>
)}
</KandevBody>
</KandevRow>
);
};
// ---------- 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 (
<KandevRow
Icon={IconPencil}
title="Kandev: Update Task Plan"
summary={
<span className="inline-flex items-center gap-1.5">
{taskId && (
<>
<IdChip id={taskId} />
<SummaryDot />
</>
)}
<span>{summarizeContent(displayContent)}</span>
</span>
}
status={status}
hasExpandableContent={!!displayContent}
>
<KandevBody>
{displayTitle && <KeyValueRow label="title">{displayTitle}</KeyValueRow>}
{displayContent && (
<ContentBox>
<MarkdownBody content={displayContent} />
</ContentBox>
)}
</KandevBody>
</KandevRow>
);
};
// ---------- delete_task_plan ----------
export const DeleteTaskPlanRenderer: KandevRenderer = ({ args, status }) => {
const taskId = pickString(args, "task_id");
return (
<KandevRow
Icon={IconTrash}
title="Kandev: Delete Task Plan"
summary={<IdChip id={taskId} />}
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 (
<KandevRow
Icon={IconFileText}
title="Kandev: Get Task Document"
summary={
<span className="inline-flex items-center gap-1.5">
<IdChip id={taskId} />
{docKey && (
<>
<SummaryDot />
<span className="font-mono text-[10px]">{docKey}</span>
</>
)}
</span>
}
status={status}
hasExpandableContent={!!content}
>
<KandevBody>
<div className="flex flex-wrap items-center gap-2">
{title && <span className="text-sm font-medium">{title}</span>}
{type && (
<Badge variant="secondary" className="text-[9px]">
{type}
</Badge>
)}
{author && <span className="text-[10px] text-muted-foreground/70">by {author}</span>}
</div>
{content && (
<ContentBox>
<MarkdownBody content={content} />
</ContentBox>
)}
</KandevBody>
</KandevRow>
);
};
// ---------- 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 (
<KandevRow
Icon={IconFile}
title="Kandev: Write Task Document"
summary={
<span className="inline-flex items-center gap-1.5">
<IdChip id={taskId} />
{docKey && (
<>
<SummaryDot />
<span className="font-mono text-[10px]">{docKey}</span>
</>
)}
</span>
}
status={status}
hasExpandableContent={!!displayContent}
>
<KandevBody>
<div className="flex flex-wrap items-center gap-2">
{title && <span className="text-sm font-medium">{title}</span>}
{type && (
<Badge variant="secondary" className="text-[9px]">
{type}
</Badge>
)}
</div>
{displayContent && (
<ContentBox>
<MarkdownBody content={displayContent} />
</ContentBox>
)}
</KandevBody>
</KandevRow>
);
};
// ---------- 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 (
<div className="text-xs space-y-0.5">
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-muted-foreground/70">
<span>{isUser ? "user" : (msg.author_type ?? "agent")}</span>
{msg.type && msg.type !== "message" && (
<Badge variant="outline" className="text-[9px]">
{msg.type}
</Badge>
)}
</div>
{msg.content && (
<div className="whitespace-pre-wrap break-words text-foreground">{msg.content}</div>
)}
</div>
);
}
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<ConversationMessage>(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 (
<KandevRow
Icon={IconMessageCircle}
title="Kandev: Get Task Conversation"
summary={
<span className="inline-flex items-center gap-1.5">
{taskId && <IdChip id={taskId} />}
{sessionId && (
<>
{taskId && <SummaryDot />}
<IdChip id={sessionId} />
</>
)}
{(taskId || sessionId) && <SummaryDot />}
{pluralCount(total, "message")}
</span>
}
status={status}
hasExpandableContent={messages.length > 0}
>
<KandevBody>
{messages.length === 0 ? (
<EmptyListNote noun="messages" />
) : (
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{visible.map((m, i) => (
<ConversationMessageRow key={m.id ?? i} msg={m} />
))}
{truncated && (
<div className="text-[10px] italic text-muted-foreground/70">
+ {hiddenCount} more not shown
</div>
)}
</div>
)}
</KandevBody>
</KandevRow>
);
};
@@ -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 (
<div className="text-xs space-y-0.5">
<div className="flex items-baseline gap-2">
<span>{name ?? "(unnamed)"}</span>
<IdChip id={id} />
</div>
{description && <div className="text-[11px] text-muted-foreground/70">{description}</div>}
</div>
);
}
// ---------- list_workspaces ----------
type WorkspaceItem = {
id?: string;
name?: string;
description?: string;
};
export const ListWorkspacesRenderer: KandevRenderer = ({ result, status }) => {
const items = pickArray<WorkspaceItem>(result, "workspaces") ?? [];
const total = pickNumber(result, "total") ?? items.length;
return (
<KandevRow
Icon={IconBriefcase}
title="Kandev: List Workspaces"
summary={pluralCount(total, "workspace")}
status={status}
hasExpandableContent={items.length > 0}
>
<KandevBody>
{items.length === 0 ? (
<EmptyListNote noun="workspaces" />
) : (
items.map((w, i) => (
<NamedListRow
key={w.id ?? w.name ?? `workspace-${i}`}
name={w.name}
description={w.description}
id={w.id}
/>
))
)}
</KandevBody>
</KandevRow>
);
};
// ---------- list_workflows ----------
type WorkflowItem = {
id?: string;
name?: string;
description?: string;
workspace_id?: string;
};
export const ListWorkflowsRenderer: KandevRenderer = ({ args, result, status }) => {
const items = pickArray<WorkflowItem>(result, "workflows") ?? [];
const total = pickNumber(result, "total") ?? items.length;
const workspaceId = pickString(args, "workspace_id");
return (
<KandevRow
Icon={IconColumns3}
title="Kandev: List Workflows"
summary={
<span className="inline-flex items-center gap-1.5">
{workspaceId && (
<>
<IdChip id={workspaceId} />
<SummaryDot />
</>
)}
{pluralCount(total, "workflow")}
</span>
}
status={status}
hasExpandableContent={items.length > 0}
>
<KandevBody>
{items.length === 0 ? (
<EmptyListNote noun="workflows" />
) : (
items.map((w, i) => (
<NamedListRow
key={w.id ?? w.name ?? `workflow-${i}`}
name={w.name}
description={w.description}
id={w.id}
/>
))
)}
</KandevBody>
</KandevRow>
);
};
// ---------- 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 <span className={cn("inline-block w-2 h-2 rounded-full shrink-0", cls)} />;
}
export const ListWorkflowStepsRenderer: KandevRenderer = ({ args, result, status }) => {
const items = pickArray<WorkflowStep>(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 (
<KandevRow
Icon={IconList}
title="Kandev: List Workflow Steps"
summary={
<span className="inline-flex items-center gap-1.5">
{workflowId && (
<>
<IdChip id={workflowId} />
<SummaryDot />
</>
)}
{pluralCount(total, "step")}
</span>
}
status={status}
hasExpandableContent={items.length > 0}
>
<KandevBody>
{items.length === 0 ? (
<EmptyListNote noun="steps" />
) : (
sorted.map((step, i) => (
<div
key={step.id ?? step.name ?? `step-${i}`}
className="flex items-center gap-2 text-xs"
>
<StepColorDot color={step.color} />
<span>{step.name ?? "(unnamed)"}</span>
{step.is_start_step && (
<span className="text-[10px] text-muted-foreground/60">start</span>
)}
{step.stage_type && step.stage_type !== "custom" && (
<span className="text-[10px] text-muted-foreground/60">{step.stage_type}</span>
)}
</div>
))
)}
</KandevBody>
</KandevRow>
);
};
// ---------- 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<TaskItem>(result, "tasks") ?? [];
const total = pickNumber(result, "total") ?? items.length;
const workflowId = pickString(args, "workflow_id");
return (
<KandevRow
Icon={IconListCheck}
title="Kandev: List Tasks"
summary={
<span className="inline-flex items-center gap-1.5">
{workflowId && (
<>
<IdChip id={workflowId} />
<SummaryDot />
</>
)}
{pluralCount(total, "task")}
</span>
}
status={status}
hasExpandableContent={items.length > 0}
>
<KandevBody>
{items.length === 0 ? (
<EmptyListNote noun="tasks" />
) : (
items.map((t, i) => (
<div key={t.id ?? t.title ?? `task-${i}`} className="flex items-baseline gap-2 text-xs">
<TaskStateBadge state={t.state} />
<span>{t.title ?? "(untitled)"}</span>
<IdChip id={t.id} />
</div>
))
)}
</KandevBody>
</KandevRow>
);
};
// ---------- 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 (
<div className="space-y-1">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/60">{label}</div>
{items.map((t, i) => (
<div
key={t.id ?? t.title ?? `related-${i}`}
className="flex items-baseline gap-2 text-xs pl-2"
>
<TaskStateBadge state={t.state} />
<span>{t.title ?? "(untitled)"}</span>
<IdChip id={t.id} />
</div>
))}
</div>
);
}
export const ListRelatedTasksRenderer: KandevRenderer = ({ args, result, status }) => {
const groups = RELATED_GROUPS.map((g) => ({
...g,
items: pickArray<RelatedTaskItem>(result, g.key) ?? [],
}));
const total = groups.reduce((sum, g) => sum + g.items.length, 0);
const taskId = pickString(args, "task_id");
return (
<KandevRow
Icon={IconLink}
title="Kandev: List Related Tasks"
summary={
<span className="inline-flex items-center gap-1.5">
{taskId && (
<>
<IdChip id={taskId} />
<SummaryDot />
</>
)}
{pluralCount(total, "related task")}
</span>
}
status={status}
hasExpandableContent={total > 0}
>
<KandevBody>
{total === 0 ? (
<EmptyListNote noun="related tasks" />
) : (
groups.map((g) => <RelatedGroup key={g.key} label={g.label} items={g.items} />)
)}
</KandevBody>
</KandevRow>
);
};
// ---------- 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<AgentItem>(result, "agents") ?? [];
const total = pickNumber(result, "total") ?? items.length;
return (
<KandevRow
Icon={IconRobot}
title="Kandev: List Agents"
summary={pluralCount(total, "agent")}
status={status}
hasExpandableContent={items.length > 0}
>
<KandevBody>
{items.length === 0 ? (
<EmptyListNote noun="agents" />
) : (
items.map((a, i) => (
<ListItemRow key={a.id ?? a.name ?? `agent-${i}`}>
<div className="font-medium">{a.name ?? a.id ?? "(unnamed)"}</div>
{a.profiles && a.profiles.length > 0 && (
<div className="text-[11px] text-muted-foreground/70">
{a.profiles
.map((p) => (p.model ? `${p.name ?? p.id} (${p.model})` : (p.name ?? p.id)))
.filter(Boolean)
.join(", ")}
</div>
)}
</ListItemRow>
))
)}
</KandevBody>
</KandevRow>
);
};
// ---------- 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<ExecutorProfileItem>(result, "profiles") ?? [];
const total = pickNumber(result, "total") ?? items.length;
const executorId = pickString(args, "executor_id");
return (
<KandevRow
Icon={IconServer}
title="Kandev: List Executor Profiles"
summary={
<span className="inline-flex items-center gap-1.5">
{executorId && (
<>
<span className="text-muted-foreground/70">{executorId}</span>
<SummaryDot />
</>
)}
{pluralCount(total, "profile")}
</span>
}
status={status}
hasExpandableContent={items.length > 0}
>
<KandevBody>
{items.length === 0 ? (
<EmptyListNote noun="profiles" />
) : (
items.map((p, i) => (
<div
key={p.id ?? p.name ?? `profile-${i}`}
className="flex items-baseline gap-2 text-xs"
>
<span>{p.name ?? "(unnamed)"}</span>
{p.mcp_policy && (
<span className="text-[10px] text-muted-foreground/60">mcp: {p.mcp_policy}</span>
)}
<IdChip id={p.id} />
</div>
))
)}
</KandevBody>
</KandevRow>
);
};
// ---------- 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<DocumentItem>(result, "documents") ?? [];
const total = pickNumber(result, "total") ?? items.length;
const taskId = pickString(args, "task_id") ?? pickString(result, "task_id");
return (
<KandevRow
Icon={IconFiles}
title="Kandev: List Task Documents"
summary={
<span className="inline-flex items-center gap-1.5">
{taskId && (
<>
<IdChip id={taskId} />
<SummaryDot />
</>
)}
{pluralCount(total, "document")}
</span>
}
status={status}
hasExpandableContent={items.length > 0}
>
<KandevBody>
{items.length === 0 ? (
<EmptyListNote noun="documents" />
) : (
items.map((d, i) => (
<div key={d.key ?? d.title ?? `doc-${i}`} className="flex items-baseline gap-2 text-xs">
<span>{d.title ?? d.key ?? "(untitled)"}</span>
{d.key && d.title && (
<span className="font-mono text-[10px] text-muted-foreground/50">{d.key}</span>
)}
{d.type && <span className="text-[10px] text-muted-foreground/60">{d.type}</span>}
{d.author && (
<span className="text-[10px] text-muted-foreground/60">by {d.author}</span>
)}
</div>
))
)}
</KandevBody>
</KandevRow>
);
};
@@ -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("");
});
});
@@ -0,0 +1,105 @@
// Parsing helpers for Kandev MCP tool calls.
//
// Tool names arrive prefixed by the agent runtime:
// - Claude Code: `mcp__kandev__<tool>_kandev`
// - Codex: `kandev/<tool>_kandev`
// - Bare: `<tool>_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<string, unknown>)[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<string, unknown>)[key];
return typeof v === "number" ? v : undefined;
}
export function pickArray<T = unknown>(obj: unknown, key: string): T[] | undefined {
if (!obj || typeof obj !== "object") return undefined;
const v = (obj as Record<string, unknown>)[key];
return Array.isArray(v) ? (v as T[]) : undefined;
}
export function pickObject(obj: unknown, key: string): Record<string, unknown> | undefined {
if (!obj || typeof obj !== "object") return undefined;
const v = (obj as Record<string, unknown>)[key];
if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
return v as Record<string, unknown>;
}
// 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)}…`;
}
@@ -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<string, KandevRenderer> = {
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;
}
@@ -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 <IconCheck className="h-3.5 w-3.5 text-green-500" />;
if (status === "error") return <IconX className="h-3.5 w-3.5 text-red-500" />;
if (status === "running") return <GridSpinner className="text-muted-foreground" />;
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 (
<ExpandableRow
icon={<Icon className="h-4 w-4 text-muted-foreground" />}
header={
<div className="flex items-center gap-2 text-xs min-w-0">
<span className="font-mono text-xs text-muted-foreground shrink-0">{title}</span>
{summary && (
<span className="text-xs text-muted-foreground/80 truncate min-w-0">{summary}</span>
)}
{!isSuccess && (
<span className="shrink-0">
<KandevStatusIcon status={status} />
</span>
)}
</div>
}
hasExpandableContent={hasExpandableContent}
isExpanded={isExpanded}
onToggle={handleToggle}
>
{children}
</ExpandableRow>
);
}
// 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 <div className="pl-4 border-l-2 border-border/30 space-y-2">{children}</div>;
}
// 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 <span className="text-muted-foreground/40">·</span>;
}
// 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 (
<span className="font-mono text-[10px] text-muted-foreground/50" title={id}>
{shortId(id)}
</span>
);
}
// 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 (
<div className="flex items-baseline gap-2 text-xs">
<span className="text-muted-foreground/70 shrink-0">{label}</span>
<span className={cn("text-foreground break-words min-w-0", mono && "font-mono text-[11px]")}>
{children}
</span>
</div>
);
}
// 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 (
<Badge variant={cfg.variant} className={cn("uppercase tracking-wide", cfg.className)}>
{state}
</Badge>
);
}
// 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 <div className="text-xs text-muted-foreground italic">No {noun} found.</div>;
}
// 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 <div className="text-xs space-y-0.5">{children}</div>;
}
// 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`}`;
}
@@ -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 (
<div className="flex flex-wrap gap-1">
{repos.map((r, i) => (
<Badge key={r.repository_id ?? r.local_path ?? i} variant="outline" className="text-[10px]">
{r.local_path ?? r.github_url ?? r.repository_id}
{r.base_branch ? ` @ ${r.base_branch}` : ""}
</Badge>
))}
</div>
);
}
// 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<string, unknown> | 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<Repository>(task, "repositories") ?? [];
return (
<>
<div className="flex items-center gap-2 flex-wrap">
<TaskStateBadge state={state} />
{title && <span className="text-sm font-medium">{title}</span>}
<IdChip id={id} />
</div>
{description && (
<div className="text-xs text-muted-foreground whitespace-pre-wrap line-clamp-4">
{description}
</div>
)}
<div className="flex flex-wrap gap-3 text-[11px] text-muted-foreground/70">
{workflowId && <IdChip id={workflowId} />}
{stepId && <IdChip id={stepId} />}
</div>
<RepoChips repos={repos} />
</>
);
}
// ---------- create_task ----------
export const CreateTaskRenderer: KandevRenderer = ({ args, result, status }) => {
const title = pickString(args, "title");
const task =
result && typeof result === "object" ? (result as Record<string, unknown>) : undefined;
const createdId = pickString(task, "id");
return (
<KandevRow
Icon={IconPlus}
title="Kandev: Create Task"
summary={
<span className="inline-flex items-center gap-1.5">
{title && <span className="truncate max-w-[40ch]">&ldquo;{title}&rdquo;</span>}
{createdId && (
<>
<SummaryDot />
<IdChip id={createdId} />
</>
)}
</span>
}
status={status}
hasExpandableContent={!!task}
>
<KandevBody>
<TaskDTOBody task={task} />
</KandevBody>
</KandevRow>
);
};
// ---------- 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<string, unknown>) : 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 (
<KandevRow
Icon={IconPencil}
title="Kandev: Update Task"
summary={
<span className="inline-flex items-center gap-1.5">
<IdChip id={taskId} />
{changes.length > 0 && (
<>
<SummaryDot />
<span>{changes.join(", ")}</span>
</>
)}
</span>
}
status={status}
hasExpandableContent={!!task || changes.length > 0}
>
<KandevBody>
{newTitle !== undefined && <KeyValueRow label="title">{newTitle}</KeyValueRow>}
{newState !== undefined && (
<KeyValueRow label="state">
<TaskStateBadge state={newState} />
</KeyValueRow>
)}
{newDescription !== undefined && (
<KeyValueRow label="description">
<span className="whitespace-pre-wrap">{newDescription}</span>
</KeyValueRow>
)}
{task && <TaskDTOBody task={task} />}
</KandevBody>
</KandevRow>
);
};
// ---------- 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<string, unknown>) : undefined;
return (
<KandevRow
Icon={IconArrowsExchange}
title="Kandev: Move Task"
summary={
<span className="inline-flex items-center gap-1.5">
<IdChip id={taskId} />
{taskId && stepId && <SummaryDot />}
<IdChip id={stepId} />
</span>
}
status={status}
hasExpandableContent={!!task || !!prompt || !!workflowId || !!stepId}
>
<KandevBody>
{workflowId && (
<KeyValueRow label="workflow" mono>
{workflowId}
</KeyValueRow>
)}
{stepId && (
<KeyValueRow label="step" mono>
{stepId}
</KeyValueRow>
)}
{prompt && (
<KeyValueRow label="prompt">
<span className="whitespace-pre-wrap">{prompt}</span>
</KeyValueRow>
)}
{task && <TaskDTOBody task={task} />}
</KandevBody>
</KandevRow>
);
};
// ---------- 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<string, unknown>) : undefined;
return (
<KandevRow
Icon={IconMessage2}
title="Kandev: Message Task"
summary={
<span className="inline-flex items-center gap-1.5 min-w-0">
<IdChip id={taskId} />
{promptShort && (
<>
<SummaryDot />
<span className="truncate max-w-[40ch]">&ldquo;{promptShort}&rdquo;</span>
</>
)}
</span>
}
status={status}
hasExpandableContent={!!prompt}
>
<KandevBody>
{prompt && (
<KeyValueRow label="prompt">
<span className="whitespace-pre-wrap">{prompt}</span>
</KeyValueRow>
)}
{session && (
<KeyValueRow label="session">
<IdChip id={sessionId} />
</KeyValueRow>
)}
</KandevBody>
</KandevRow>
);
};
@@ -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 `<KandevRow>` element.
import type { ReactElement } from "react";
import type { KandevStatus } from "./shared";
export type KandevRendererProps = {
args: Record<string, unknown> | undefined;
result: unknown;
status: KandevStatus;
};
export type KandevRenderer = (props: KandevRendererProps) => ReactElement;