408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useState } from "react";
|
|
import { IconBrain, IconGitMerge, IconX } from "@tabler/icons-react";
|
|
import { getWebSocketClient } from "@/lib/ws/connection";
|
|
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
|
|
import { SHORTCUTS } from "@/lib/keyboard/constants";
|
|
import { useMessageHandler } from "@/hooks/use-message-handler";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { type ContextFile } from "@/lib/state/context-files-store";
|
|
import {
|
|
ChatInputContainer,
|
|
type ChatInputContainerHandle,
|
|
type MessageAttachment,
|
|
} from "@/components/task/chat/chat-input-container";
|
|
import {
|
|
formatReviewCommentsAsMarkdown,
|
|
formatPRFeedbackAsMarkdown,
|
|
formatPlanCommentsAsMarkdown,
|
|
} from "@/lib/state/slices/comments/format";
|
|
import { setChatDraftContent } from "@/lib/local-storage";
|
|
import { useTaskActions } from "@/hooks/use-task-actions";
|
|
import { useToast } from "@/components/toast-provider";
|
|
import type { DiffComment } from "@/lib/diff/types";
|
|
import type { useChatPanelState } from "./use-chat-panel-state";
|
|
|
|
const PLAN_CONTEXT_PATH = "plan:context";
|
|
|
|
function buildSubmitMessage(
|
|
message: string,
|
|
reviewComments: DiffComment[] | undefined,
|
|
pendingPRFeedback: import("@/lib/state/slices/comments").PRFeedbackComment[],
|
|
planComments: import("@/lib/state/slices/comments").PlanComment[],
|
|
): string {
|
|
let finalMessage = message;
|
|
if (reviewComments && reviewComments.length > 0) {
|
|
finalMessage = formatReviewCommentsAsMarkdown(reviewComments) + (message || "");
|
|
}
|
|
if (pendingPRFeedback.length > 0) {
|
|
finalMessage = formatPRFeedbackAsMarkdown(pendingPRFeedback) + finalMessage;
|
|
}
|
|
if (planComments.length > 0) {
|
|
const planMarkdown = formatPlanCommentsAsMarkdown(planComments);
|
|
const header = "### Plan Comments\n\n";
|
|
finalMessage = finalMessage
|
|
? `${header}${planMarkdown}\n\n---\n\n${finalMessage}`
|
|
: `${header}${planMarkdown}`;
|
|
}
|
|
return finalMessage;
|
|
}
|
|
|
|
function resolveInputPlaceholder(
|
|
isAgentBusy: boolean,
|
|
activeDocumentType: string | undefined,
|
|
planModeEnabled: boolean,
|
|
hasClarification: boolean,
|
|
): string {
|
|
if (hasClarification) return "Answer the question above to continue...";
|
|
if (isAgentBusy) return "Queue instructions to the agent...";
|
|
if (activeDocumentType === "file") return "Continue working on the file...";
|
|
if (planModeEnabled) return "Continue working on the plan...";
|
|
return "Continue working on the task...";
|
|
}
|
|
|
|
export function useSubmitHandler(
|
|
panelState: ReturnType<typeof useChatPanelState>,
|
|
onSend?: (message: string) => void,
|
|
) {
|
|
const [isSending, setIsSending] = useState(false);
|
|
const {
|
|
resolvedSessionId,
|
|
sessionModel,
|
|
activeModel,
|
|
isAgentBusy,
|
|
activeDocument,
|
|
planComments,
|
|
pendingPRFeedback,
|
|
contextFiles,
|
|
prompts,
|
|
markCommentsSent,
|
|
clearSessionPlanComments,
|
|
handleClearPRFeedback,
|
|
clearEphemeral,
|
|
addContextFile,
|
|
planModeEnabled,
|
|
} = panelState;
|
|
const { handleSendMessage } = useMessageHandler({
|
|
resolvedSessionId,
|
|
taskId: panelState.taskId,
|
|
sessionModel,
|
|
activeModel,
|
|
planModeEnabled: panelState.planModeEnabled,
|
|
isAgentBusy,
|
|
activeDocument,
|
|
planComments,
|
|
contextFiles,
|
|
prompts,
|
|
});
|
|
|
|
const handleSubmit = useCallback(
|
|
async (
|
|
message: string,
|
|
reviewComments?: DiffComment[],
|
|
attachments?: MessageAttachment[],
|
|
inlineMentions?: ContextFile[],
|
|
) => {
|
|
if (isSending) return;
|
|
setIsSending(true);
|
|
try {
|
|
const finalMessage = buildSubmitMessage(
|
|
message,
|
|
reviewComments,
|
|
pendingPRFeedback,
|
|
planComments,
|
|
);
|
|
const hasReviewComments = !!(reviewComments && reviewComments.length > 0);
|
|
if (onSend) {
|
|
await onSend(finalMessage);
|
|
} else {
|
|
await handleSendMessage(finalMessage, attachments, hasReviewComments, inlineMentions);
|
|
}
|
|
if (reviewComments && reviewComments.length > 0)
|
|
markCommentsSent(reviewComments.map((c) => c.id));
|
|
if (pendingPRFeedback.length > 0) handleClearPRFeedback();
|
|
if (planComments.length > 0) clearSessionPlanComments();
|
|
if (resolvedSessionId) {
|
|
clearEphemeral(resolvedSessionId);
|
|
// Re-add plan context if plan mode is still active (clearEphemeral removes unpinned files)
|
|
if (planModeEnabled) {
|
|
addContextFile(resolvedSessionId, { path: PLAN_CONTEXT_PATH, name: "Plan" });
|
|
}
|
|
}
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
},
|
|
[
|
|
isSending,
|
|
onSend,
|
|
handleSendMessage,
|
|
markCommentsSent,
|
|
planComments,
|
|
clearSessionPlanComments,
|
|
pendingPRFeedback,
|
|
handleClearPRFeedback,
|
|
resolvedSessionId,
|
|
clearEphemeral,
|
|
planModeEnabled,
|
|
addContextFile,
|
|
],
|
|
);
|
|
|
|
return { isSending, handleSubmit };
|
|
}
|
|
|
|
export function useChatPanelHandlers(
|
|
resolvedSessionId: string | null,
|
|
cancelQueue: () => Promise<void>,
|
|
chatInputRef: React.RefObject<ChatInputContainerHandle | null>,
|
|
) {
|
|
const handleCancelTurn = useCallback(async () => {
|
|
if (!resolvedSessionId) return;
|
|
const client = getWebSocketClient();
|
|
if (!client) return;
|
|
try {
|
|
await client.request("agent.cancel", { session_id: resolvedSessionId }, 15000);
|
|
} catch (error) {
|
|
console.error("Failed to cancel agent turn:", error);
|
|
}
|
|
}, [resolvedSessionId]);
|
|
|
|
const handleCancelQueue = useCallback(async () => {
|
|
try {
|
|
await cancelQueue();
|
|
} catch (error) {
|
|
console.error("Failed to cancel queued message:", error);
|
|
}
|
|
}, [cancelQueue]);
|
|
|
|
const handleQueueEditComplete = useCallback(() => {
|
|
chatInputRef.current?.focusInput();
|
|
}, [chatInputRef]);
|
|
|
|
useKeyboardShortcut(
|
|
SHORTCUTS.FOCUS_INPUT,
|
|
useCallback(
|
|
(event: KeyboardEvent) => {
|
|
const el = document.activeElement;
|
|
const isTyping =
|
|
el instanceof HTMLInputElement ||
|
|
el instanceof HTMLTextAreaElement ||
|
|
(el instanceof HTMLElement && el.isContentEditable);
|
|
if (isTyping) return;
|
|
const inputHandle = chatInputRef.current;
|
|
if (inputHandle) {
|
|
event.preventDefault();
|
|
inputHandle.focusInput();
|
|
}
|
|
},
|
|
[chatInputRef],
|
|
),
|
|
{ enabled: true, preventDefault: false },
|
|
);
|
|
|
|
return { handleCancelTurn, handleCancelQueue, handleQueueEditComplete };
|
|
}
|
|
|
|
function useImplementPlan(
|
|
resolvedSessionId: string | null,
|
|
taskId: string | null,
|
|
handlePlanModeChange: (enabled: boolean) => void,
|
|
chatInputRef: React.RefObject<ChatInputContainerHandle | null>,
|
|
) {
|
|
return useCallback(() => {
|
|
if (!resolvedSessionId || !taskId) return;
|
|
|
|
const client = getWebSocketClient();
|
|
if (!client) return;
|
|
|
|
const userText = chatInputRef.current?.getValue() ?? "";
|
|
chatInputRef.current?.clear();
|
|
if (resolvedSessionId) {
|
|
setChatDraftContent(resolvedSessionId, null);
|
|
}
|
|
|
|
const visibleText = userText.trim() || "Implement the plan";
|
|
const content = `${visibleText}\n\n<kandev-system>
|
|
IMPLEMENT PLAN: The user has approved the plan and wants you to implement it now.
|
|
Read the current plan using the get_task_plan_kandev MCP tool.
|
|
Implement all changes described in the plan step by step.
|
|
After completing the implementation, provide a summary of what was done.
|
|
</kandev-system>`;
|
|
|
|
client
|
|
.request(
|
|
"message.add",
|
|
{
|
|
task_id: taskId,
|
|
session_id: resolvedSessionId,
|
|
content,
|
|
plan_mode: false,
|
|
},
|
|
10000,
|
|
)
|
|
.catch((err: unknown) => console.error("Failed to send implement plan message:", err));
|
|
|
|
handlePlanModeChange(false);
|
|
}, [resolvedSessionId, taskId, handlePlanModeChange, chatInputRef]);
|
|
}
|
|
|
|
function PRMergedBanner({ taskId }: { taskId: string }) {
|
|
const taskPR = useAppStore((state) => state.taskPRs.byTaskId[taskId]);
|
|
const [dismissed, setDismissed] = useState(false);
|
|
const { archiveTaskById } = useTaskActions();
|
|
const { toast } = useToast();
|
|
|
|
const handleArchive = useCallback(async () => {
|
|
try {
|
|
await archiveTaskById(taskId);
|
|
toast({ description: "Task archived" });
|
|
} catch {
|
|
toast({ description: "Failed to archive task", variant: "error" });
|
|
}
|
|
}, [taskId, archiveTaskById, toast]);
|
|
|
|
if (taskPR?.state !== "merged" || dismissed) return null;
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 mx-1 mb-1 px-3 py-1.5 text-xs rounded-md bg-purple-500/10 text-purple-600 dark:text-purple-400">
|
|
<IconGitMerge className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="flex-1">
|
|
PR #{taskPR.pr_number} has been merged. You can archive this task.
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleArchive}
|
|
className="underline underline-offset-2 hover:text-purple-700 dark:hover:text-purple-300 cursor-pointer"
|
|
>
|
|
Archive
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="Dismiss"
|
|
onClick={() => setDismissed(true)}
|
|
className="p-0.5 hover:bg-purple-500/10 rounded cursor-pointer"
|
|
>
|
|
<IconX className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ChatInputAreaProps = {
|
|
chatInputRef: React.RefObject<ChatInputContainerHandle | null>;
|
|
clarificationKey: number;
|
|
onClarificationResolved: () => void;
|
|
handleSubmit: (
|
|
message: string,
|
|
reviewComments?: DiffComment[],
|
|
attachments?: MessageAttachment[],
|
|
inlineMentions?: ContextFile[],
|
|
) => Promise<void>;
|
|
handleCancelTurn: () => Promise<void>;
|
|
showRequestChangesTooltip: boolean;
|
|
onRequestChangesTooltipDismiss?: () => void;
|
|
panelState: ReturnType<typeof useChatPanelState>;
|
|
isSending: boolean;
|
|
};
|
|
|
|
export function ChatInputArea({
|
|
chatInputRef,
|
|
clarificationKey,
|
|
onClarificationResolved,
|
|
handleSubmit,
|
|
handleCancelTurn,
|
|
showRequestChangesTooltip,
|
|
onRequestChangesTooltipDismiss,
|
|
panelState,
|
|
isSending,
|
|
}: ChatInputAreaProps) {
|
|
const {
|
|
resolvedSessionId,
|
|
task,
|
|
taskId,
|
|
taskDescription,
|
|
isStarting,
|
|
isAgentBusy,
|
|
isFailed,
|
|
planModeEnabled,
|
|
activeDocument,
|
|
handlePlanModeChange,
|
|
contextFiles,
|
|
handleToggleContextFile,
|
|
handleAddContextFile,
|
|
pendingCommentsByFile,
|
|
chatSubmitKey,
|
|
agentCommands,
|
|
contextItems,
|
|
planContextEnabled,
|
|
todoItems,
|
|
} = panelState;
|
|
const handleImplementPlan = useImplementPlan(
|
|
resolvedSessionId,
|
|
taskId,
|
|
handlePlanModeChange,
|
|
chatInputRef,
|
|
);
|
|
|
|
const agentMode = useAppStore((state) =>
|
|
resolvedSessionId ? state.sessionMode.bySessionId[resolvedSessionId] : undefined,
|
|
);
|
|
|
|
const hasClarification = !!panelState.pendingClarification;
|
|
const placeholder = resolveInputPlaceholder(
|
|
isAgentBusy,
|
|
activeDocument?.type,
|
|
planModeEnabled,
|
|
hasClarification,
|
|
);
|
|
return (
|
|
<div className="bg-card flex-shrink-0 px-2 pb-2 pt-1">
|
|
{agentMode && agentMode !== "default" && (
|
|
<div className="flex items-center gap-1.5 px-3 py-1 text-xs text-muted-foreground">
|
|
<IconBrain className="h-3 w-3" />
|
|
<span className="capitalize">{agentMode} mode</span>
|
|
</div>
|
|
)}
|
|
{taskId && <PRMergedBanner key={taskId} taskId={taskId} />}
|
|
<ChatInputContainer
|
|
ref={chatInputRef}
|
|
key={clarificationKey}
|
|
onSubmit={handleSubmit}
|
|
sessionId={resolvedSessionId}
|
|
taskId={taskId}
|
|
taskTitle={task?.title}
|
|
taskDescription={taskDescription ?? ""}
|
|
planModeEnabled={planModeEnabled}
|
|
planModeAvailable={panelState.planModeAvailable}
|
|
mcpServers={panelState.mcpServers}
|
|
onPlanModeChange={handlePlanModeChange}
|
|
isAgentBusy={isAgentBusy}
|
|
isStarting={isStarting}
|
|
isSending={isSending}
|
|
onCancel={handleCancelTurn}
|
|
placeholder={placeholder}
|
|
pendingClarification={panelState.pendingClarification}
|
|
onClarificationResolved={onClarificationResolved}
|
|
showRequestChangesTooltip={showRequestChangesTooltip}
|
|
onRequestChangesTooltipDismiss={onRequestChangesTooltipDismiss}
|
|
pendingCommentsByFile={pendingCommentsByFile}
|
|
hasContextComments={
|
|
panelState.planComments.length > 0 || panelState.pendingPRFeedback.length > 0
|
|
}
|
|
submitKey={chatSubmitKey}
|
|
hasAgentCommands={!!(agentCommands && agentCommands.length > 0)}
|
|
isFailed={isFailed}
|
|
contextItems={contextItems}
|
|
planContextEnabled={planContextEnabled}
|
|
contextFiles={contextFiles}
|
|
onToggleContextFile={handleToggleContextFile}
|
|
onAddContextFile={handleAddContextFile}
|
|
todoItems={todoItems}
|
|
onImplementPlan={handleImplementPlan}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|