"use client"; import { forwardRef, useCallback, useState } from "react"; import { IconAlertTriangle, IconPlayerPlay, IconRefresh } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { NewSessionDialog } from "@/components/task/new-session-dialog"; import { useAppStore } from "@/components/state-provider"; import type { ContextFile } from "@/lib/state/context-files-store"; import type { ClarificationRequestMetadata, Message } from "@/lib/types/http"; import type { DiffComment } from "@/lib/diff/types"; import type { TaskMentionData } from "@/hooks/use-inline-mention"; import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types"; import type { EntityReference } from "@/lib/types/entity-reference"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useChatInputContainer } from "./use-chat-input-container"; import { ChatInputBody, type ChatInputContextAreaProps, type ChatInputEditorAreaProps, } from "./chat-input-body"; import type { ContextItem } from "@/lib/types/context"; import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured"; import { usePromptResultDelivery } from "@/hooks/use-prompt-result-delivery"; import { PromptResultRecovery } from "@/components/prompt-result-recovery"; import { useTranslation } from "react-i18next"; import { t } from "@/lib/i18n"; // Re-export ImageAttachment type for consumers export type { ImageAttachment } from "./image-attachment-preview"; // Type for message attachments sent to backend export type MessageAttachment = { type: "image" | "audio" | "resource"; data?: string; attachment_id?: string; mime_type: string; name?: string; size_bytes?: number; delivery_mode?: "prompt" | "path"; }; export type ChatInputContainerHandle = { focusInput: () => void; getTextareaElement: () => HTMLElement | null; getValue: () => string; getSelectionStart: () => number; insertText: (text: string, from: number, to: number) => void; clear: () => void; getAttachments: () => MessageAttachment[]; }; export type ChatSubmitResult = void | boolean | Promise; export type ChatSubmitPayload = { message: string; reviewComments?: DiffComment[]; attachments?: MessageAttachment[]; inlineMentions?: ContextFile[]; inlineTaskMentions?: TaskMentionData[]; entityReferences?: EntityReference[]; }; type ChatInputContainerProps = { onSubmit: (payload: ChatSubmitPayload) => ChatSubmitResult; sessionId: string | null; taskId: string | null; workspaceId?: string | null; entityReferencesEnabled?: boolean; taskTitle?: string; taskDescription: string; planModeEnabled: boolean; planModeAvailable?: boolean; mcpServers?: string[]; mcpAttachmentHistory?: MCPAttachmentHistory; onPlanModeChange: (enabled: boolean) => void; isAgentBusy: boolean; /** True when a send would be delivered into the running turn (mid-turn * steering) rather than queued. Defaults to false. */ supportsSteering?: boolean; isStarting: boolean; /** True only while a containerized executor is bootstrapping (Docker * prepare, Sprites sandbox spin-up). Distinct from the brief STARTING * state every session — including local quick-chat — passes through; * see useSessionState. */ isPreparingEnvironment?: boolean; isMoving?: boolean; isSending: boolean; onCancel: () => void; placeholder?: string; pendingClarification?: Message | null; onClarificationResolved?: () => void; showRequestChangesTooltip?: boolean; onRequestChangesTooltipDismiss?: () => void; pendingCommentsByFile?: Record; hasContextComments?: boolean; submitKey?: "enter" | "cmd_enter"; hasAgentCommands?: boolean; isFailed?: boolean; needsRecovery?: boolean; executorUnavailable?: boolean; executorUnavailableReason?: string; contextItems?: ContextItem[]; planContextEnabled?: boolean; contextFiles?: ContextFile[]; onToggleContextFile?: (file: ContextFile) => void; onAddContextFile?: (file: ContextFile) => void; onImplementPlan?: (fresh: boolean) => void; hideSessionsDropdown?: boolean; minimalToolbar?: boolean; hideAgentControls?: boolean; /** Hide the plan mode toggle button (for ephemeral/quick chat sessions) */ hidePlanMode?: boolean; }; async function requestSessionRecover( taskId: string, sessionId: string, action: "resume" | "fresh_start", ): Promise { const client = getWebSocketClient(); if (!client) return false; try { await client.request( "session.recover", { task_id: taskId, session_id: sessionId, action }, 30000, ); return true; } catch { return false; } } // The recovery banner keeps resume/fresh-start controls together for desktop // and mobile layouts; its line count is intentionally bounded by that UI. // eslint-disable-next-line max-lines-per-function function FailedSessionBanner({ showDialog, onShowDialog, taskId, sessionId, workspaceId, message = "This agent has stopped.", detail, resumeLabel = "Resume", resumingLabel = "Resuming...", }: { showDialog: boolean; onShowDialog: (open: boolean) => void; taskId: string | null; sessionId: string | null; workspaceId?: string | null; message?: string; detail?: string; resumeLabel?: string; resumingLabel?: string; }) { const { t } = useTranslation(); const [isResuming, setIsResuming] = useState(false); const [isStartingFresh, setIsStartingFresh] = useState(false); const agentProfileId = useAppStore((s) => sessionId ? (s.taskSessions.items[sessionId]?.agent_profile_id ?? "") : "", ); const profileExists = useAppStore( (s) => agentProfileId !== "" && s.agentProfiles.items.some((p: { id: string }) => p.id === agentProfileId), ); const handleRecover = useCallback( async (action: "resume" | "fresh_start") => { if (!sessionId || !taskId) return; const setBusy = action === "resume" ? setIsResuming : setIsStartingFresh; setBusy(true); const ok = await requestSessionRecover(taskId, sessionId, action); if (!ok) setBusy(false); }, [sessionId, taskId], ); const handleResume = useCallback(() => handleRecover("resume"), [handleRecover]); const handleFreshStart = useCallback(() => { if (!profileExists) { onShowDialog(true); return; } void handleRecover("fresh_start"); }, [profileExists, onShowDialog, handleRecover]); return ( <>
{message} {detail && ({detail})}
{sessionId && taskId && ( {!profileExists && ( {t("task:agentProfileNoLongerExists")} )} )}
{taskId && ( )} ); } type ContainerState = ReturnType; type NormalizedChatInputProps = ChatInputContainerProps & { isFailed: boolean; hasAgentCommands: boolean; submitKey: "enter" | "cmd_enter"; planContextEnabled: boolean; contextFiles: ContextFile[]; contextItems: ContextItem[]; showRequestChangesTooltip: boolean; entityReferencesEnabled: boolean; }; function normalizeChatInputProps(p: ChatInputContainerProps): NormalizedChatInputProps { return { ...p, isFailed: p.isFailed ?? false, hasAgentCommands: p.hasAgentCommands ?? false, submitKey: p.submitKey ?? "cmd_enter", planContextEnabled: p.planContextEnabled ?? false, contextFiles: p.contextFiles ?? [], contextItems: p.contextItems ?? [], showRequestChangesTooltip: p.showRequestChangesTooltip ?? false, entityReferencesEnabled: p.entityReferencesEnabled ?? false, }; } function buildContextAreaProps( s: ContainerState, p: ChatInputContainerProps, ): ChatInputContextAreaProps { return { hasContextZone: s.hasContextZone, allItems: s.allItems, sessionId: p.sessionId, }; } type EnhancePromptExtras = { onEnhancePrompt?: () => void; isEnhancingPrompt?: boolean; isUtilityConfigured?: boolean; onVoiceTranscript?: (text: string) => void; onVoiceAutoSend?: () => void; }; export function shouldShowCancelAgent( isAgentBusy: boolean, pendingClarification: Message | null | undefined, ): boolean { if (!pendingClarification) return isAgentBusy; return !(pendingClarification.metadata as ClarificationRequestMetadata | undefined) ?.agent_disconnected; } function buildEditorAreaProps( s: ContainerState, p: NormalizedChatInputProps, extras: EnhancePromptExtras = {}, ): ChatInputEditorAreaProps { return { inputRef: s.inputRef, value: s.value, handleChange: s.handleChange, handleSubmitWithReset: s.handleSubmitWithReset, inputPlaceholder: s.inputPlaceholder, isDisabled: s.isDisabled, submitDisabled: s.submitDisabled, submitDisabledReason: s.submitDisabledReason, hasPendingAttachmentUploads: s.hasPendingAttachmentUploads, planModeEnabled: p.planModeEnabled, planModeAvailable: p.planModeAvailable ?? true, mcpServers: p.mcpServers ?? [], mcpAttachmentHistory: p.mcpAttachmentHistory, submitKey: p.submitKey, setIsInputFocused: s.setIsInputFocused, sessionId: p.sessionId, taskId: p.taskId, workspaceId: p.workspaceId ?? null, entityReferencesEnabled: p.entityReferencesEnabled, onAddContextFile: p.onAddContextFile, onToggleContextFile: p.onToggleContextFile, planContextEnabled: p.planContextEnabled, addFiles: s.addFiles, fileInputRef: s.fileInputRef, showRequestChangesTooltip: p.showRequestChangesTooltip, isAgentBusy: p.isAgentBusy || !!(p.pendingClarification && p.onClarificationResolved), canCancelAgent: shouldShowCancelAgent(p.isAgentBusy, p.pendingClarification), onPlanModeChange: p.onPlanModeChange, taskTitle: p.taskTitle, taskDescription: p.taskDescription, isSending: p.isSending, onCancel: p.onCancel, contextCount: s.allItems.length, contextPopoverOpen: s.contextPopoverOpen, setContextPopoverOpen: s.setContextPopoverOpen, contextFiles: p.contextFiles, onImplementPlan: p.onImplementPlan, onEnhancePrompt: extras.onEnhancePrompt, isEnhancingPrompt: extras.isEnhancingPrompt, isUtilityConfigured: extras.isUtilityConfigured, onVoiceTranscript: extras.onVoiceTranscript, onVoiceAutoSend: extras.onVoiceAutoSend, hideSessionsDropdown: p.hideSessionsDropdown, minimalToolbar: p.minimalToolbar, hideAgentControls: p.hideAgentControls, hidePlanMode: p.hidePlanMode, }; } function buildStoppedBannerProps(p: ChatInputContainerProps) { if (!p.executorUnavailable) return {}; return { message: t("task:executorEnvironmentIsUnavailable"), detail: p.executorUnavailableReason, resumeLabel: t("task:restart"), resumingLabel: t("task:restarting"), }; } function applyEnhancedPromptToEditor(inputRef: ContainerState["inputRef"], value: string): boolean { const input = inputRef.current; if (!input) { return false; } input.setValue(value); return input.getValue() === value; } function useChatPromptEnhancement({ inputRef, taskId, sessionId, taskTitle, taskDescription, }: { inputRef: ContainerState["inputRef"]; taskId: string | null; sessionId: string | null; taskTitle?: string; taskDescription: string; }) { const isUtilityConfigured = useIsUtilityConfigured(); const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({ sessionId, taskTitle, taskDescription, }); const getCurrentEditorValue = useCallback(() => inputRef.current?.getValue() ?? null, [inputRef]); const applyEnhancedPrompt = useCallback( (nextValue: string) => applyEnhancedPromptToEditor(inputRef, nextValue), [inputRef], ); const promptDelivery = usePromptResultDelivery({ scopeKey: `chat:${taskId ?? ""}:${sessionId ?? ""}`, getCurrent: getCurrentEditorValue, apply: applyEnhancedPrompt, }); const handleEnhancePrompt = useCallback(() => { const currentValue = getCurrentEditorValue(); if (currentValue === null) return; if (!currentValue.trim()) return; const generation = promptDelivery.captureScope(); void enhancePrompt(currentValue, (result) => promptDelivery.deliver(currentValue, result, generation), ); }, [getCurrentEditorValue, enhancePrompt, promptDelivery]); return { handleEnhancePrompt, isEnhancingPrompt, isUtilityConfigured, promptDelivery }; } function insertVoiceTranscript(inputRef: ContainerState["inputRef"], text: string): void { const editor = inputRef.current; if (!editor) return; const trimmed = text.trim(); if (!trimmed) return; const cursor = editor.getSelectionStart(); const current = editor.getValue(); const charBefore = cursor > 0 ? current.charAt(cursor - 1) : ""; const needsLeadingSpace = charBefore !== "" && !/\s/.test(charBefore); const insert = needsLeadingSpace ? ` ${trimmed}` : trimmed; editor.insertText(insert, cursor, cursor); } export const ChatInputContainer = forwardRef( function ChatInputContainer(props, ref) { const { sessionId, taskId, taskTitle, taskDescription, isAgentBusy, isStarting, isSending } = props; const p = normalizeChatInputProps(props); const isMoving = props.isMoving ?? false; const executorUnavailable = props.executorUnavailable ?? false; const isBusyVisual = isStarting || isMoving; const s = useChatInputContainer({ ref, sessionId, workspaceId: props.workspaceId, isSending, isStarting, isPreparingEnvironment: props.isPreparingEnvironment ?? false, isMoving, isFailed: p.isFailed, needsRecovery: props.needsRecovery ?? false, executorUnavailable, isAgentBusy, supportsSteering: props.supportsSteering ?? false, hasAgentCommands: p.hasAgentCommands, placeholder: props.placeholder, contextItems: p.contextItems, pendingClarification: props.pendingClarification, onClarificationResolved: props.onClarificationResolved, pendingCommentsByFile: props.pendingCommentsByFile, hasContextComments: props.hasContextComments ?? false, showRequestChangesTooltip: p.showRequestChangesTooltip, onRequestChangesTooltipDismiss: props.onRequestChangesTooltipDismiss, onSubmit: props.onSubmit, }); const promptEnhancement = useChatPromptEnhancement({ inputRef: s.inputRef, taskId, sessionId, taskTitle, taskDescription, }); const handleVoiceTranscript = useCallback( (text: string) => { insertVoiceTranscript(s.inputRef, text); }, [s.inputRef], ); // Auto-send fires the same submit path as the regular send button. Guards // against firing while the input is in a disabled state (e.g. the agent // is currently booting) — the button is hidden in that case anyway, but // defence-in-depth so a stale keyboard shortcut press doesn't trigger. const { submitDisabled: voiceSubmitDisabled, handleSubmitWithReset: voiceSubmit } = s; const handleVoiceAutoSend = useCallback(() => { if (voiceSubmitDisabled) return; voiceSubmit(); }, [voiceSubmitDisabled, voiceSubmit]); if (p.isFailed || executorUnavailable) { return ( ); } return ( ) : null } editorAreaProps={buildEditorAreaProps(s, p, { onEnhancePrompt: promptEnhancement.handleEnhancePrompt, isEnhancingPrompt: promptEnhancement.isEnhancingPrompt, isUtilityConfigured: promptEnhancement.isUtilityConfigured, onVoiceTranscript: handleVoiceTranscript, onVoiceAutoSend: handleVoiceAutoSend, })} /> ); }, );