"use client"; import { useCallback, useState, type ReactNode } from "react"; import { IconArrowRight, IconGitMerge, IconGitPullRequestClosed, IconX } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { TodoIndicator } from "./todo-indicator"; import { PRStatusChip } from "@/components/github/pr-status-chip"; import { ShareButton, shareableSessionStateClient } from "@/components/task/share/share-button"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { useMessageHandler, buildTaskMentionsContext } from "@/hooks/use-message-handler"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; import { type ContextFile } from "@/lib/state/context-files-store"; import type { TaskMentionData } from "@/hooks/use-inline-mention"; import { ChatInputContainer, type ChatInputContainerHandle, type MessageAttachment, } from "@/components/task/chat/chat-input-container"; import { QueueAffordance } from "@/components/task/chat/queued-ghost-list"; import { formatReviewCommentsAsMarkdown, formatPRFeedbackAsMarkdown, formatPlanCommentsAsMarkdown, } from "@/lib/state/slices/comments/format"; import { usePlanActions } from "@/hooks/domains/kanban/use-plan-actions"; import { useExecutorEnvironmentAvailability } from "@/hooks/domains/session/use-executor-environment-availability"; import { useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; import { useToast } from "@/components/toast-provider"; import { markPRClosedBannerDismissed, markPRMergedBannerDismissed, wasPRClosedBannerDismissed, wasPRMergedBannerDismissed, } from "@/lib/local-storage"; 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); finalMessage = finalMessage ? `${planMarkdown}${finalMessage}` : planMarkdown; } return finalMessage; } function resolveInputPlaceholder( isAgentBusy: boolean, activeDocumentType: string | undefined, planModeEnabled: boolean, hasClarification: boolean, needsRecovery: boolean, ): string { if (needsRecovery) return "Choose a recovery option above to continue..."; 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..."; } type PlaceholderArgs = { override: string | undefined; isMoving: boolean; isAgentBusy: boolean; activeDocumentType: string | undefined; planModeEnabled: boolean; hasClarification: boolean; needsRecovery: boolean; }; function pickInputPlaceholder(a: PlaceholderArgs): string { if (a.isMoving) return "Switching agent..."; // Preserve the prior `??` semantics: an explicit "" override (caller wants // no placeholder text) must NOT fall through to the resolver default. if (a.override !== undefined) return a.override; return resolveInputPlaceholder( a.isAgentBusy, a.activeDocumentType, a.planModeEnabled, a.hasClarification, a.needsRecovery, ); } export function useSubmitHandler( panelState: ReturnType, onSend?: (message: string) => void, ) { const [isSending, setIsSending] = useState(false); const storeApi = useAppStoreApi(); 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[], inlineTaskMentions?: TaskMentionData[], ) => { if (isSending) return; setIsSending(true); try { const finalMessage = buildSubmitMessage( message, reviewComments, pendingPRFeedback, planComments, ); const hasReviewComments = !!(reviewComments && reviewComments.length > 0); if (onSend) { // The onSend path bypasses useMessageHandler.buildFinalMessage, so // expand task mentions here — otherwise the task chips show in the // editor but the agent never receives the block. const taskCtx = inlineTaskMentions?.length ? buildTaskMentionsContext(inlineTaskMentions, storeApi.getState()) : ""; await onSend(finalMessage + taskCtx); } else { await handleSendMessage( finalMessage, attachments, hasReviewComments, inlineMentions, inlineTaskMentions, ); } 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, storeApi, handleSendMessage, markCommentsSent, planComments, clearSessionPlanComments, pendingPRFeedback, handleClearPRFeedback, resolvedSessionId, clearEphemeral, planModeEnabled, addContextFile, ], ); return { isSending, handleSubmit }; } export function useChatPanelHandlers( resolvedSessionId: string | null, chatInputRef: React.RefObject, ) { 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 keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); useKeyboardShortcut( getShortcut("FOCUS_INPUT", keyboardShortcuts), 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 }; } // Shared archive-task action for the terminal-state banners: archives the task, // switches to the next one, and toasts the outcome. function useArchiveTaskAction(taskId: string) { const archiveAndSwitch = useArchiveAndSwitchTask(); const { toast } = useToast(); return useCallback(async () => { try { await archiveAndSwitch(taskId); toast({ description: "Task archived" }); } catch { toast({ description: "Failed to archive task", variant: "error" }); } }, [taskId, archiveAndSwitch, toast]); } // Presentational banner shared by PRMergedBanner / PRClosedBanner — an icon, a // message, and Archive + Dismiss controls. Colors/icon/testIds are supplied by // the caller so the two variants stay visually distinct. function ArchiveDismissBanner({ testIdPrefix, icon, text, containerClass, archiveClass, dismissClass, onArchive, onDismiss, }: { testIdPrefix: string; icon: ReactNode; text: string; containerClass: string; archiveClass: string; dismissClass: string; onArchive: () => void; onDismiss: () => void; }) { return (
{icon} {text}
); } export function PRMergedBanner({ taskId }: { taskId: string }) { const taskPRs = useAppStore((state) => state.taskPRs.byTaskId[taskId]); const [dismissed, setDismissed] = useState(() => wasPRMergedBannerDismissed(taskId)); const handleArchive = useArchiveTaskAction(taskId); const handleDismiss = useCallback(() => { markPRMergedBannerDismissed(taskId); setDismissed(true); }, [taskId]); // Multi-repo: only show "ready to archive" once every PR is merged. A // single merged repo with others still open means the task isn't done yet. const allMerged = !!taskPRs && taskPRs.length > 0 && taskPRs.every((pr) => pr.state === "merged"); if (!allMerged || dismissed) return null; const bannerText = taskPRs.length === 1 ? `PR #${taskPRs[0].pr_number} has been merged. You can archive this task.` : `All ${taskPRs.length} PRs have been merged. You can archive this task.`; return ( } text={bannerText} containerClass="flex flex-1 items-center gap-2 rounded-md bg-purple-500/10 px-2 py-1 text-purple-600 dark:text-purple-400" archiveClass="underline underline-offset-2 hover:text-purple-700 dark:hover:text-purple-300 cursor-pointer" dismissClass="p-0.5 hover:bg-purple-500/10 rounded cursor-pointer" onArchive={handleArchive} onDismiss={handleDismiss} /> ); } export function PRClosedBanner({ taskId }: { taskId: string }) { const taskPRs = useAppStore((state) => state.taskPRs.byTaskId[taskId]); const [dismissed, setDismissed] = useState(() => wasPRClosedBannerDismissed(taskId)); const handleArchive = useArchiveTaskAction(taskId); const handleDismiss = useCallback(() => { markPRClosedBannerDismissed(taskId); setDismissed(true); }, [taskId]); // Mirror the merged banner's all-or-nothing rule: show only once every PR is // closed-without-merging. A mix of merged + closed shows neither banner. const allClosed = !!taskPRs && taskPRs.length > 0 && taskPRs.every((pr) => pr.state === "closed"); if (!allClosed || dismissed) return null; const bannerText = taskPRs.length === 1 ? `PR #${taskPRs[0].pr_number} was closed without merging. You can archive this task.` : `All ${taskPRs.length} PRs were closed without merging. You can archive this task.`; return ( } text={bannerText} containerClass="flex flex-1 items-center gap-2 rounded-md bg-red-500/10 px-2 py-1 text-red-600 dark:text-red-400" archiveClass="underline underline-offset-2 hover:text-red-700 dark:hover:text-red-300 cursor-pointer" dismissClass="p-0.5 hover:bg-red-500/10 rounded cursor-pointer" onArchive={handleArchive} onDismiss={handleDismiss} /> ); } type TodoDisplayItem = { text: string; done?: boolean; status?: "pending" | "in_progress" | "completed" | "failed"; }; function ChatStatusBar({ todoItems, taskId, sessionId, sessionState, nextStepName, onProceed, isAgentBusy, isMoving, queueChip, }: { todoItems: TodoDisplayItem[]; taskId: string | null; sessionId: string | null; sessionState: string | null; nextStepName: string | null; onProceed: () => void; isAgentBusy: boolean; isMoving: boolean; queueChip?: ReactNode; }) { const showTodos = todoItems.length > 0; const showProceed = !!nextStepName && !isAgentBusy; const canShare = !!taskId && !!sessionId && shareableSessionStateClient(sessionState); // PRMergedBanner returns null internally when not applicable return (
{showTodos && } {queueChip} {/* Distinct per-banner keys: the key remounts the banner on task switch so its dismissed state re-initialises, and keeping the two suffixes different avoids a duplicate-sibling-key collision. */} {taskId && } {taskId && } {canShare && taskId && sessionId && (
)} {showProceed && ( Move task to the next workflow step )}
); } type ChatInputAreaProps = { chatInputRef: React.RefObject; clarificationKey: number; onClarificationResolved: () => void; handleSubmit: ( message: string, reviewComments?: DiffComment[], attachments?: MessageAttachment[], inlineMentions?: ContextFile[], inlineTaskMentions?: TaskMentionData[], ) => Promise; handleCancelTurn: () => Promise; showRequestChangesTooltip: boolean; onRequestChangesTooltipDismiss?: () => void; panelState: ReturnType; isSending: boolean; hideSessionsDropdown?: boolean; minimalToolbar?: boolean; /** Hide the plan mode toggle button (for ephemeral/quick chat sessions) */ hidePlanMode?: boolean; placeholderOverride?: string; }; function useExecutorUnavailable(taskId: string | null, sessionId: string | null) { const availability = useExecutorEnvironmentAvailability(taskId, Boolean(sessionId && taskId)); return { unavailable: availability.unavailable, reason: availability.status?.label, }; } function useChatInputDerived( panelState: ReturnType, chatInputRef: React.RefObject, placeholderOverride: string | undefined, ) { const { resolvedSessionId, taskId, isAgentBusy, needsRecovery, planModeEnabled, activeDocument } = panelState; const planActions = usePlanActions({ resolvedSessionId, taskId, planModeEnabled, handlePlanModeChange: panelState.handlePlanModeChange, chatInputRef, }); const hasClarification = !!panelState.pendingClarification; const executor = useExecutorUnavailable(taskId, resolvedSessionId); const placeholder = pickInputPlaceholder({ override: placeholderOverride, isMoving: planActions.isMoving, isAgentBusy, activeDocumentType: activeDocument?.type, planModeEnabled, hasClarification, needsRecovery, }); return { planActions, executor, placeholder }; } export function ChatInputArea({ chatInputRef, clarificationKey, onClarificationResolved, handleSubmit, handleCancelTurn, showRequestChangesTooltip, onRequestChangesTooltipDismiss, panelState, isSending, hideSessionsDropdown, minimalToolbar, hidePlanMode, placeholderOverride, }: ChatInputAreaProps) { const { resolvedSessionId, taskId, isAgentBusy, needsRecovery, planModeEnabled, todoItems } = panelState; const sessionState = panelState.session?.state ?? null; const canDrainQueue = sessionState === "WAITING_FOR_INPUT" || sessionState === "IDLE"; const { planActions, executor, placeholder } = useChatInputDerived( panelState, chatInputRef, placeholderOverride, ); const { implementPlanHandler, proceedStepName, proceed, isMoving } = planActions; return (
( )} > 0 || panelState.pendingPRFeedback.length > 0 } submitKey={panelState.chatSubmitKey} hasAgentCommands={!!(panelState.agentCommands && panelState.agentCommands.length > 0)} isFailed={panelState.isFailed} needsRecovery={needsRecovery} executorUnavailable={executor.unavailable} executorUnavailableReason={executor.reason} contextItems={panelState.contextItems} planContextEnabled={panelState.planContextEnabled} contextFiles={panelState.contextFiles} onToggleContextFile={panelState.handleToggleContextFile} onAddContextFile={panelState.handleAddContextFile} onImplementPlan={implementPlanHandler} hideSessionsDropdown={hideSessionsDropdown} minimalToolbar={minimalToolbar} hidePlanMode={hidePlanMode} />
); }