487 lines
17 KiB
TypeScript
487 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useState, useCallback, type ReactElement } from "react";
|
|
import { IconPlayerPlay } from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { 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 {
|
|
buildResumeRequest,
|
|
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<string, Message>;
|
|
childrenByParentToolCallId?: Map<string, Message[]>;
|
|
worktreePath?: string;
|
|
sessionId?: string;
|
|
onOpenFile?: (path: 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 their Start agent button from
|
|
* the message-list footer 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;
|
|
}
|
|
|
|
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 resumeSkipped = useAppStore(
|
|
(state) => state.tasks.resumeSkippedSessionIds[sessionId] === true,
|
|
);
|
|
const setResumeSkipped = useAppStore((state) => state.setResumeSkipped);
|
|
const session = useAppStore((state) => state.taskSessions.items[sessionId] ?? null);
|
|
const setTaskSession = useAppStore((state) => state.setTaskSession);
|
|
|
|
const handleStart = useCallback(async () => {
|
|
setIsStarting(true);
|
|
try {
|
|
// Never-started (CREATED) sessions use the start_created intent; a
|
|
// resume-skipped (recovered-idle) session resumes its existing agent.
|
|
const { request } = resumeSkipped
|
|
? buildResumeRequest(taskId, sessionId)
|
|
: 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/resume 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 ?? "",
|
|
});
|
|
}
|
|
}
|
|
// Only confirmed RUNNING clears the resume-skipped marker: a resume
|
|
// response commonly reports STARTING (launch accepted, agent starting),
|
|
// and the WS RUNNING transition clears it later. Failed launches keep
|
|
// the button as a retry affordance.
|
|
if (response.state === "RUNNING") {
|
|
setResumeSkipped(sessionId, false);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to start agent:", error);
|
|
} finally {
|
|
setIsStarting(false);
|
|
}
|
|
}, [taskId, sessionId, resumeSkipped, setResumeSkipped, session, setTaskSession]);
|
|
|
|
// Hide while environment is being prepared
|
|
if (prepareStatus === "preparing") return null;
|
|
|
|
return (
|
|
<div className="flex justify-end mt-1.5">
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
className="cursor-pointer gap-1.5"
|
|
onClick={handleStart}
|
|
disabled={isStarting}
|
|
data-testid="task-description-start-button"
|
|
>
|
|
<IconPlayerPlay className="h-3.5 w-3.5" />
|
|
{isStarting ? t("task:startingEllipsis") : t("task:startAgent")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<ChatMessage
|
|
comment={comment}
|
|
label={t("task:agent")}
|
|
className="bg-muted/40 text-foreground border-border/60"
|
|
showRichBlocks={comment.type === "message" || comment.type === "content" || !comment.type}
|
|
sessionId={sessionId}
|
|
isTurnActive={isTurnActive}
|
|
worktreePath={worktreePath}
|
|
onOpenFile={onOpenFile}
|
|
onScrollToMessage={onScrollToMessage}
|
|
/>
|
|
);
|
|
}
|
|
const showStartButton = shouldShowDescriptionStartButton({
|
|
sessionState,
|
|
taskState: task?.state,
|
|
taskId,
|
|
sessionId,
|
|
});
|
|
return (
|
|
<>
|
|
<ChatMessage
|
|
comment={comment}
|
|
label={t("task:you")}
|
|
className="bg-primary/10 text-foreground border-primary/30"
|
|
sessionId={sessionId}
|
|
isTurnActive={isTurnActive}
|
|
worktreePath={worktreePath}
|
|
onOpenFile={onOpenFile}
|
|
onScrollToMessage={onScrollToMessage}
|
|
/>
|
|
{showStartButton && <TaskDescriptionStartButton taskId={taskId!} sessionId={sessionId!} />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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) => (
|
|
<ThinkingMessage
|
|
comment={comment}
|
|
worktreePath={ctx.worktreePath}
|
|
onOpenFile={ctx.onOpenFile}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
matches: (comment) => comment.type === "todo",
|
|
render: (comment) => <TodoMessage comment={comment} />,
|
|
},
|
|
{
|
|
matches: (comment) => comment.type === "tool_edit",
|
|
render: (comment, ctx) => (
|
|
<ToolEditMessage
|
|
comment={comment}
|
|
worktreePath={ctx.worktreePath}
|
|
sessionId={ctx.sessionId}
|
|
onOpenFile={ctx.onOpenFile}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
matches: (comment) => comment.type === "tool_read",
|
|
render: (comment, ctx) => (
|
|
<ToolReadMessage
|
|
comment={comment}
|
|
worktreePath={ctx.worktreePath}
|
|
sessionId={ctx.sessionId}
|
|
onOpenFile={ctx.onOpenFile}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
matches: (comment) => comment.type === "tool_search",
|
|
render: (comment, ctx) => (
|
|
<ToolSearchMessage
|
|
comment={comment}
|
|
worktreePath={ctx.worktreePath}
|
|
onOpenFile={ctx.onOpenFile}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
matches: (comment) => comment.type === "tool_execute",
|
|
render: (comment, ctx) => (
|
|
<ToolExecuteMessage comment={comment} worktreePath={ctx.worktreePath} />
|
|
),
|
|
},
|
|
{
|
|
// Claude-acp's Monitor tool — long-lived background script with streaming
|
|
// events. Rendered with a dedicated card that shows watching state, event
|
|
// count, and the most recent event tail. Detected via the structured
|
|
// `monitor` view the adapter writes into the Generic payload's output
|
|
// wrapper (presence-based rather than title-based so renames upstream
|
|
// don't break the match). Must run BEFORE the generic tool_call adapter.
|
|
matches: (comment) => {
|
|
if (comment.type !== "tool_call") return false;
|
|
const meta = comment.metadata as ToolCallMetadata | undefined;
|
|
const out = meta?.normalized?.generic?.output as { monitor?: unknown } | undefined;
|
|
return !!out && typeof out === "object" && !!out.monitor;
|
|
},
|
|
render: (comment) => <MonitorMessage comment={comment} />,
|
|
},
|
|
{
|
|
// Subagent Task tool calls with nested children
|
|
matches: (comment, ctx) => {
|
|
if (comment.type !== "tool_call") return false;
|
|
const metadata = comment.metadata as ToolCallMetadata | undefined;
|
|
const isSubagent = metadata?.normalized?.kind === "subagent_task";
|
|
const toolCallId = metadata?.tool_call_id;
|
|
const hasChildren = toolCallId
|
|
? (ctx.childrenByParentToolCallId?.has(toolCallId) ?? false)
|
|
: false;
|
|
return isSubagent || hasChildren;
|
|
},
|
|
render: (comment, ctx) => {
|
|
const toolCallId = (comment.metadata as ToolCallMetadata | undefined)?.tool_call_id;
|
|
const childMessages = toolCallId
|
|
? (ctx.childrenByParentToolCallId?.get(toolCallId) ?? [])
|
|
: [];
|
|
|
|
// Create a render function for child messages
|
|
const renderChild = (child: Message) => {
|
|
// Recursively use MessageRenderer for children (without subagent nesting)
|
|
const childCtx = { ...ctx, childrenByParentToolCallId: undefined };
|
|
const adapter =
|
|
adapters.find((entry) => entry.matches(child, childCtx)) ?? adapters[adapters.length - 1];
|
|
return adapter.render(child, childCtx);
|
|
};
|
|
|
|
return (
|
|
<ToolSubagentMessage
|
|
comment={comment}
|
|
childMessages={childMessages}
|
|
isContainingTurnActive={ctx.isContainingTurnActive}
|
|
worktreePath={ctx.worktreePath}
|
|
onOpenFile={ctx.onOpenFile}
|
|
renderChild={renderChild}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
// Kandev MCP tools — `mcp__kandev__list_tasks_kandev` etc. — get
|
|
// per-tool structured rendering. Must run BEFORE the generic tool_call
|
|
// adapter so the unwrapped + structured view wins. Unrecognised Kandev
|
|
// tools (no matching renderer) fall through to the generic adapter.
|
|
matches: (comment) => hasKandevRenderer(comment),
|
|
render: (comment, ctx) => {
|
|
const toolCallId = (comment.metadata as { tool_call_id?: string } | undefined)?.tool_call_id;
|
|
const permissionMessage = toolCallId
|
|
? ctx.permissionsByToolCallId?.get(toolCallId)
|
|
: undefined;
|
|
return <KandevToolMessage comment={comment} permissionMessage={permissionMessage} />;
|
|
},
|
|
},
|
|
{
|
|
matches: (comment) => comment.type === "tool_call",
|
|
render: (comment, ctx) => {
|
|
const toolCallId = (comment.metadata as { tool_call_id?: string } | undefined)?.tool_call_id;
|
|
const permissionMessage = toolCallId
|
|
? ctx.permissionsByToolCallId?.get(toolCallId)
|
|
: undefined;
|
|
return (
|
|
<ToolCallMessage
|
|
comment={comment}
|
|
permissionMessage={permissionMessage}
|
|
worktreePath={ctx.worktreePath}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
matches: (comment) => {
|
|
const meta = comment.metadata as Record<string, unknown> | undefined;
|
|
return Array.isArray(meta?.actions) && (meta.actions as unknown[]).length > 0;
|
|
},
|
|
render: (comment) => <ActionMessage comment={comment} />,
|
|
},
|
|
{
|
|
matches: (comment) =>
|
|
comment.type === "error" || comment.type === "status" || comment.type === "progress",
|
|
render: (comment) => <StatusMessage comment={comment} />,
|
|
},
|
|
{
|
|
// Standalone permission requests (no matching tool call)
|
|
matches: (comment) => comment.type === "permission_request",
|
|
render: (comment) => <PermissionRequestMessage comment={comment} />,
|
|
},
|
|
{
|
|
matches: (comment) => comment.type === "clarification_request",
|
|
render: (comment) => <ClarificationRequestMessage comment={comment} />,
|
|
},
|
|
{
|
|
matches: (comment) => comment.type === "agent_plan",
|
|
render: (comment, ctx) => (
|
|
<AgentPlanMessage
|
|
comment={comment}
|
|
worktreePath={ctx.worktreePath}
|
|
onOpenFile={ctx.onOpenFile}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
matches: (comment) => comment.type === "script_execution",
|
|
render: (comment) => <ScriptExecutionMessage comment={comment} />,
|
|
},
|
|
{
|
|
matches: () => true,
|
|
render: (comment, ctx) => {
|
|
if (ctx.isTaskDescription) {
|
|
return (
|
|
<TaskDescriptionMessage
|
|
comment={comment}
|
|
taskId={ctx.taskId}
|
|
sessionId={ctx.sessionId}
|
|
worktreePath={ctx.worktreePath}
|
|
onOpenFile={ctx.onOpenFile}
|
|
onScrollToMessage={ctx.onScrollToMessage}
|
|
isTurnActive={ctx.isTurnActive}
|
|
/>
|
|
);
|
|
}
|
|
if (comment.author_type === "user") {
|
|
return (
|
|
<ChatMessage
|
|
comment={comment}
|
|
label={t("task:you")}
|
|
className="bg-primary/10 text-foreground border-primary/30"
|
|
sessionId={ctx.sessionId}
|
|
worktreePath={ctx.worktreePath}
|
|
onOpenFile={ctx.onOpenFile}
|
|
onScrollToMessage={ctx.onScrollToMessage}
|
|
isTurnActive={ctx.isTurnActive}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<ChatMessage
|
|
comment={comment}
|
|
label={t("task:agent")}
|
|
className="bg-muted/40 text-foreground border-border/60"
|
|
showRichBlocks={comment.type === "message" || comment.type === "content" || !comment.type}
|
|
sessionId={ctx.sessionId}
|
|
worktreePath={ctx.worktreePath}
|
|
onOpenFile={ctx.onOpenFile}
|
|
onScrollToMessage={ctx.onScrollToMessage}
|
|
isTurnActive={ctx.isTurnActive}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
type MessageRendererProps = {
|
|
comment: Message;
|
|
isTaskDescription: boolean;
|
|
taskId?: string;
|
|
permissionsByToolCallId?: Map<string, Message>;
|
|
childrenByParentToolCallId?: Map<string, Message[]>;
|
|
worktreePath?: string;
|
|
sessionId?: string;
|
|
onOpenFile?: (path: 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);
|
|
});
|