"use client"; import { memo, useState, useCallback, type ReactElement } from "react"; import { IconPlayerPlay } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { sessionId as toSessionId, taskId as toTaskId } from "@/lib/types/http"; import type { Message, TaskSessionState } from "@/lib/types/http"; import type { ToolCallMetadata } from "@/components/task/chat/types"; import { launchSession } from "@/lib/services/session-launch-service"; import { isLaunchStateRegression } from "@/lib/session-state"; import { buildStartCreatedRequest } from "@/lib/services/session-launch-helpers"; import { useAppStore } from "@/components/state-provider"; import { useTask } from "@/hooks/use-task"; import { ChatMessage } from "@/components/task/chat/messages/chat-message"; import { PermissionRequestMessage } from "@/components/task/chat/messages/permission-request-message"; import { StatusMessage } from "@/components/task/chat/messages/status-message"; import { ToolCallMessage } from "@/components/task/chat/messages/tool-call-message"; import { ToolEditMessage } from "@/components/task/chat/messages/tool-edit-message"; import { ToolReadMessage } from "@/components/task/chat/messages/tool-read-message"; import { ToolSearchMessage } from "@/components/task/chat/messages/tool-search-message"; import { ToolExecuteMessage } from "@/components/task/chat/messages/tool-execute-message"; import { ThinkingMessage } from "@/components/task/chat/messages/thinking-message"; import { TodoMessage } from "@/components/task/chat/messages/todo-message"; import { ScriptExecutionMessage } from "@/components/task/chat/messages/script-execution-message"; import { ClarificationRequestMessage } from "@/components/task/chat/messages/clarification-request-message"; import { ToolSubagentMessage } from "@/components/task/chat/messages/tool-subagent-message"; import { MonitorMessage } from "@/components/task/chat/messages/monitor-message"; import { AgentPlanMessage } from "@/components/task/chat/messages/agent-plan-message"; import { ActionMessage } from "@/components/task/chat/messages/action-message"; import { KandevToolMessage, hasKandevRenderer, } from "@/components/task/chat/messages/kandev-tool-message"; import { useTranslation } from "react-i18next"; import { t } from "@/lib/i18n"; type AdapterContext = { isTaskDescription: boolean; taskId?: string; permissionsByToolCallId?: Map; childrenByParentToolCallId?: Map; worktreePath?: string; sessionId?: string; onOpenFile?: (path: string, repo?: string) => void; onScrollToMessage?: (messageId: string) => void; isTurnActive?: boolean; isContainingTurnActive?: boolean; }; /** * Decides whether the task-description message renders the Start agent * button. Shown only for never-started (CREATED) sessions; resume-skipped * (prevent-auto-start-on-open) sessions get the composer hint affordance * instead (which also covers empty sessions and empty descriptions). Hidden * while the task is SCHEDULING (the launch is in flight) and when no * task/session context is bound. */ export function shouldShowDescriptionStartButton({ sessionState, taskState, taskId, sessionId, }: { sessionState: TaskSessionState | undefined; taskState?: string; taskId?: string; sessionId?: string; }): boolean { return sessionState === "CREATED" && taskState !== "SCHEDULING" && !!taskId && !!sessionId; } /** * "Start agent" button inside the task-description message, shown only for * never-started (CREATED) sessions. Dispatches the start_created launch and * hides while the workspace environment is being prepared. */ function TaskDescriptionStartButton({ taskId, sessionId }: { taskId: string; sessionId: string }) { const { t } = useTranslation(); const [isStarting, setIsStarting] = useState(false); const prepareStatus = useAppStore( (state) => state.prepareProgress.bySessionId[sessionId]?.status ?? null, ); const session = useAppStore((state) => state.taskSessions.items[sessionId] ?? null); const setTaskSession = useAppStore((state) => state.setTaskSession); const handleStart = useCallback(async () => { setIsStarting(true); try { // This button only renders for never-started (CREATED) sessions // (shouldShowDescriptionStartButton); resume-skipped (recovered-idle) // sessions get their affordance from the composer hint instead, so the // launch intent is always start_created. const { request } = buildStartCreatedRequest(taskId, sessionId); const response = await launchSession(request); if (response.success && response.state) { // Hydrate the launch state (commonly STARTING) so the button hides // immediately and repeated start_created requests cannot fire // against an already-starting session before the WS transition lands. // Never apply it over a newer live state: a WS RUNNING/FAILED // transition can land before the launch response resolves, and the // delayed STARTING must not hide a running agent or a failure's // recovery affordances. if (!isLaunchStateRegression(session?.state, response.state)) { setTaskSession({ id: toSessionId(sessionId), task_id: toTaskId(taskId), state: response.state as TaskSessionState, started_at: session?.started_at ?? "", updated_at: session?.updated_at ?? "", }); } } } catch (error) { console.error("Failed to start agent:", error); } finally { setIsStarting(false); } }, [taskId, sessionId, session, setTaskSession]); // Hide while environment is being prepared if (prepareStatus === "preparing") return null; return (
); } function useSessionStateValue(sessionId?: string): TaskSessionState | undefined { return useAppStore((state) => sessionId ? (state.taskSessions.items[sessionId]?.state ?? undefined) : undefined, ); } /** * The task-description message is the only row whose rendering depends on the * live session/task state, so it reads them from the store directly. Keeping * sessionState/taskState off the shared MessageRenderer props means a state * transition no longer re-renders every message in the list. */ function TaskDescriptionMessage({ comment, taskId, sessionId, worktreePath, onOpenFile, onScrollToMessage, isTurnActive = false, }: { comment: Message; taskId?: string; sessionId?: string; worktreePath?: string; onOpenFile?: (path: string) => void; onScrollToMessage?: (messageId: string) => void; isTurnActive?: boolean; }) { const { t } = useTranslation(); const sessionState = useSessionStateValue(sessionId); const task = useTask(taskId ?? null); const renderAsUser = comment.author_type === "user" || sessionState !== "FAILED"; if (!renderAsUser) { return ( ); } const showStartButton = shouldShowDescriptionStartButton({ sessionState, taskState: task?.state, taskId, sessionId, }); return ( <> {showStartButton && } ); } type MessageAdapter = { matches: (comment: Message, ctx: AdapterContext) => boolean; render: (comment: Message, ctx: AdapterContext) => ReactElement; }; const adapters: MessageAdapter[] = [ { matches: (comment) => comment.type === "thinking", render: (comment, ctx) => ( ), }, { matches: (comment) => comment.type === "todo", render: (comment) => , }, { matches: (comment) => comment.type === "tool_edit", render: (comment, ctx) => ( ), }, { matches: (comment) => comment.type === "tool_read", render: (comment, ctx) => ( ), }, { matches: (comment) => comment.type === "tool_search", render: (comment, ctx) => ( ), }, { matches: (comment) => comment.type === "tool_execute", render: (comment, ctx) => ( ), }, { // Claude-acp's Monitor tool — long-lived background script with streaming // events. Rendered with a dedicated card that shows watching state, event // count, and the most recent event tail. Detected via the structured // `monitor` view the adapter writes into the Generic payload's output // wrapper (presence-based rather than title-based so renames upstream // don't break the match). Must run BEFORE the generic tool_call adapter. matches: (comment) => { if (comment.type !== "tool_call") return false; const meta = comment.metadata as ToolCallMetadata | undefined; const out = meta?.normalized?.generic?.output as { monitor?: unknown } | undefined; return !!out && typeof out === "object" && !!out.monitor; }, render: (comment) => , }, { // Subagent Task tool calls with nested children matches: (comment, ctx) => { if (comment.type !== "tool_call") return false; const metadata = comment.metadata as ToolCallMetadata | undefined; const isSubagent = metadata?.normalized?.kind === "subagent_task"; const toolCallId = metadata?.tool_call_id; const hasChildren = toolCallId ? (ctx.childrenByParentToolCallId?.has(toolCallId) ?? false) : false; return isSubagent || hasChildren; }, render: (comment, ctx) => { const toolCallId = (comment.metadata as ToolCallMetadata | undefined)?.tool_call_id; const childMessages = toolCallId ? (ctx.childrenByParentToolCallId?.get(toolCallId) ?? []) : []; // Create a render function for child messages const renderChild = (child: Message) => { // Recursively use MessageRenderer for children (without subagent nesting) const childCtx = { ...ctx, childrenByParentToolCallId: undefined }; const adapter = adapters.find((entry) => entry.matches(child, childCtx)) ?? adapters[adapters.length - 1]; return adapter.render(child, childCtx); }; return ( ); }, }, { // Kandev MCP tools — `mcp__kandev__list_tasks_kandev` etc. — get // per-tool structured rendering. Must run BEFORE the generic tool_call // adapter so the unwrapped + structured view wins. Unrecognised Kandev // tools (no matching renderer) fall through to the generic adapter. matches: (comment) => hasKandevRenderer(comment), render: (comment, ctx) => { const toolCallId = (comment.metadata as { tool_call_id?: string } | undefined)?.tool_call_id; const permissionMessage = toolCallId ? ctx.permissionsByToolCallId?.get(toolCallId) : undefined; return ( ); }, }, { matches: (comment) => comment.type === "tool_call", render: (comment, ctx) => { const toolCallId = (comment.metadata as { tool_call_id?: string } | undefined)?.tool_call_id; const permissionMessage = toolCallId ? ctx.permissionsByToolCallId?.get(toolCallId) : undefined; return ( ); }, }, { matches: (comment) => { const meta = comment.metadata as Record | undefined; return Array.isArray(meta?.actions) && (meta.actions as unknown[]).length > 0; }, render: (comment) => , }, { matches: (comment) => comment.type === "error" || comment.type === "status" || comment.type === "progress", render: (comment) => , }, { // Standalone permission requests (no matching tool call) matches: (comment) => comment.type === "permission_request", render: (comment) => , }, { matches: (comment) => comment.type === "clarification_request", render: (comment) => , }, { matches: (comment) => comment.type === "agent_plan", render: (comment, ctx) => ( ), }, { matches: (comment) => comment.type === "script_execution", render: (comment) => , }, { matches: () => true, render: (comment, ctx) => { if (ctx.isTaskDescription) { return ( ); } if (comment.author_type === "user") { return ( ); } return ( ); }, }, ]; type MessageRendererProps = { comment: Message; isTaskDescription: boolean; taskId?: string; permissionsByToolCallId?: Map; childrenByParentToolCallId?: Map; worktreePath?: string; sessionId?: string; onOpenFile?: (path: string, repo?: string) => void; onScrollToMessage?: (messageId: string) => void; isTurnActive?: boolean; isContainingTurnActive?: boolean; }; export const MessageRenderer = memo(function MessageRenderer({ comment, isTaskDescription, taskId, permissionsByToolCallId, childrenByParentToolCallId, worktreePath, sessionId, onOpenFile, onScrollToMessage, isTurnActive = false, isContainingTurnActive = false, }: MessageRendererProps) { const ctx = { isTaskDescription, taskId, permissionsByToolCallId, childrenByParentToolCallId, worktreePath, sessionId, onOpenFile, onScrollToMessage, isTurnActive, isContainingTurnActive, }; const adapter = adapters.find((entry) => entry.matches(comment, ctx)) ?? adapters[adapters.length - 1]; return adapter.render(comment, ctx); });