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:
@@ -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]">“{promptShort}”</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]">“{title}”</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]">“{promptShort}”</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;
|
||||
Reference in New Issue
Block a user