"use client"; import { memo, useCallback, useRef, useState, type ReactNode } from "react"; import { IconArrowUp, IconChevronsLeft, IconDots, IconFileTextSpark, IconPlayerPauseFilled, IconAt, IconPlugConnected, IconPlugConnectedX, IconPaperclip, } from "@tabler/icons-react"; import { EnhancePromptButton } from "@/components/enhance-prompt-button"; import { GridSpinner } from "@/components/grid-spinner"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@/lib/utils"; import { useToolbarCollapsed } from "@/hooks/use-toolbar-collapsed"; import { SHORTCUTS } from "@/lib/keyboard/constants"; import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip"; import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; import { formatShortcut } from "@/lib/keyboard/utils"; import { useAppStore } from "@/components/state-provider"; import { TokenUsageDisplay } from "@/components/task/chat/token-usage-display"; import { SessionsDropdown } from "@/components/task/sessions-dropdown"; import { ModelSelector } from "@/components/task/model-selector"; import { ModeSelector } from "@/components/task/mode-selector"; import { ContextPopover } from "./context-popover"; import { ResetContextButton } from "./reset-context-button"; import { ImplementPlanButton } from "./implement-plan-button"; import { VoiceInputButton } from "./voice-input-button"; import type { ContextFile } from "@/lib/state/context-files-store"; export type ChatInputToolbarProps = { planModeEnabled: boolean; planModeAvailable?: boolean; mcpServers?: string[]; onPlanModeChange: (enabled: boolean) => void; sessionId: string | null; taskId: string | null; taskTitle?: string; taskDescription: string; isAgentBusy: boolean; /** Whether the input has content to send (text, comments, or context) */ hasContent?: boolean; isDisabled: boolean; submitDisabledReason?: string; isSending: boolean; onCancel: () => void | Promise; onSubmit: () => void; submitKey?: "enter" | "cmd_enter"; contextCount?: number; contextPopoverOpen?: boolean; onContextPopoverOpenChange?: (open: boolean) => void; /** Whether plan is selected as context in the popover (independent of plan panel) */ planContextEnabled?: boolean; contextFiles?: ContextFile[]; onToggleFile?: (file: ContextFile) => void; onImplementPlan?: (fresh: boolean) => void; /** Callback to enhance the current prompt with AI */ onEnhancePrompt?: () => void; /** Whether prompt enhancement is in progress */ isEnhancingPrompt?: boolean; /** Whether utility agent is configured for AI enhancement */ isUtilityConfigured?: boolean; /** Callback to open file picker for attaching files */ onAttachFiles?: () => void; /** Callback to insert a transcribed voice utterance into the editor. When * omitted, the voice button is hidden — keeps quick-chat / read-only * variants free of a button they can't wire. */ onVoiceTranscript?: (text: string) => void; /** Optional auto-send hook fired after a voice transcript is inserted. */ onVoiceAutoSend?: () => void; /** Hide the sessions dropdown (for quick chat) */ hideSessionsDropdown?: boolean; /** When true, only render the submit/cancel button — no other controls */ minimalToolbar?: boolean; /** Hide the plan mode toggle button (for ephemeral/quick chat sessions) */ hidePlanMode?: boolean; }; type SubmitButtonProps = { isAgentBusy: boolean; hasContent: boolean; isDisabled: boolean; submitDisabledReason?: string; isSending: boolean; planModeEnabled: boolean; onCancel: () => void | Promise; onSubmit: () => void; submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS]; }; function submitTooltipDescription( isAgentBusy: boolean, planModeEnabled: boolean, submitDisabledReason?: string, ) { if (submitDisabledReason) return submitDisabledReason; if (isAgentBusy) return "Queue message"; if (planModeEnabled) return "Request plan changes"; return undefined; } function SubmitButton({ isAgentBusy, hasContent, isDisabled, submitDisabledReason, isSending, planModeEnabled, onCancel, onSubmit, submitShortcut, }: SubmitButtonProps) { // When agent is busy and there's nothing to send, show only the cancel button. // When there's content to queue, show both cancel and send buttons side-by-side. const showSendButton = !isAgentBusy || hasContent; // Track cancel-in-flight locally so impatient retries are blocked at the // button itself: cancelling a long-running tool (e.g. Claude Monitor) can // take several seconds, and without this the user clicks repeatedly and // each click hits the backend. const [isCancelling, setIsCancelling] = useState(false); const tooltipDescription = submitTooltipDescription( isAgentBusy, planModeEnabled, submitDisabledReason, ); // Re-entrancy guard via ref: `disabled={isCancelling}` already blocks DOM // clicks, but the ref makes the guard effective for any programmatic caller // and keeps `isCancelling` out of the useCallback deps so the handler // identity is stable across the false→true→false flips. const cancellingRef = useRef(false); const handleCancelClick = useCallback(async () => { if (cancellingRef.current) return; cancellingRef.current = true; setIsCancelling(true); try { await onCancel(); } catch (error) { // Parent handlers normally swallow errors, but a rejected onCancel // must not propagate as an unhandled rejection — and the button must // re-enable so the user can retry. console.error("Failed to cancel agent turn:", error); } finally { cancellingRef.current = false; setIsCancelling(false); } }, [onCancel]); return (
{isAgentBusy && ( {isCancelling ? "Cancelling..." : "Cancel agent"} )} {showSendButton && ( )}
); } function PlanToggleButton({ planModeEnabled, planModeAvailable, onPlanModeChange, }: { planModeEnabled: boolean; planModeAvailable: boolean; onPlanModeChange: (enabled: boolean) => void; }) { const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); const planModeShortcutLabel = formatShortcut(getShortcut("TOGGLE_PLAN_MODE", keyboardShortcuts)); const tooltip = planModeAvailable ? `Toggle plan mode (${planModeShortcutLabel}) — Agent collaborates on the plan without implementing changes` : `Toggle plan layout (${planModeShortcutLabel}) — View and edit the plan (agent cannot read/write it without MCP)`; return ( {tooltip} ); } function McpIndicator({ mcpServers }: { mcpServers: string[] }) { const hasMcp = mcpServers.length > 0; const tooltipText = hasMcp ? `MCP Servers: ${mcpServers.join(", ")}` : "Agent does not support MCP"; const Icon = hasMcp ? IconPlugConnected : IconPlugConnectedX; return (
{tooltipText}
); } type ToolbarItemConfig = { id: string; collapsible: boolean; section: "left" | "right"; render: () => ReactNode; visible?: boolean; }; function ToolbarExpandToggle({ isExpanded, onToggle, }: { isExpanded: boolean; onToggle: () => void; }) { return ( {isExpanded ? "Collapse" : "More actions"} ); } function ToolbarRightSection({ showCollapsed, rightItems, sessionId, planModeEnabled, isAgentBusy, hasContent, onImplementPlan, isDisabled, submitDisabledReason, isSending, onCancel, onSubmit, submitShortcut, onVoiceTranscript, onVoiceAutoSend, }: { showCollapsed: boolean; rightItems: ToolbarItemConfig[]; sessionId: string | null; planModeEnabled: boolean; isAgentBusy: boolean; hasContent: boolean; onImplementPlan?: (fresh: boolean) => void; isDisabled: boolean; submitDisabledReason?: string; isSending: boolean; onCancel: () => void; onSubmit: () => void; submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS]; onVoiceTranscript?: (text: string) => void; onVoiceAutoSend?: () => void; }) { return (
{!showCollapsed && } {planModeEnabled && !isAgentBusy && onImplementPlan && ( )}
{onVoiceTranscript && ( )}
); } function buildCollapsibleItems(props: { mcpServers: string[]; sessionId: string | null; taskId: string | null; taskTitle?: string; taskDescription: string; hideSessionsDropdown?: boolean; isAgentBusy: boolean; onEnhancePrompt?: () => void; isEnhancingPrompt: boolean; isUtilityConfigured: boolean; }): ToolbarItemConfig[] { return [ { id: "mcp", section: "left", collapsible: true, render: () => , }, { id: "mode", section: "left", collapsible: true, render: () => , }, { id: "reset-context", section: "right", collapsible: true, visible: !!props.sessionId && !props.isAgentBusy, render: () => , }, { id: "sessions", section: "right", collapsible: true, visible: !props.hideSessionsDropdown, render: () => ( ), }, { id: "model", section: "right", collapsible: true, render: () => , }, { id: "enhance", section: "right", collapsible: true, visible: !props.isAgentBusy, render: () => ( {})} isLoading={props.isEnhancingPrompt} isConfigured={props.isUtilityConfigured} /> ), }, ]; } function CollapsibleItems({ items, testIdPrefix, }: { items: ToolbarItemConfig[]; testIdPrefix: string; }) { return items.map((i) => (
{i.render()}
)); } function AttachFilesButton({ onClick }: { onClick: () => void }) { return ( Attach files ); } function MinimalToolbar({ isAgentBusy, hasContent, isDisabled, submitDisabledReason, isSending, onCancel, onSubmit, submitKey = "cmd_enter", }: Pick< ChatInputToolbarProps, | "isAgentBusy" | "hasContent" | "isDisabled" | "submitDisabledReason" | "isSending" | "onCancel" | "onSubmit" | "submitKey" >) { const submitShortcut = submitKey === "enter" ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT; return (
); } const toolbarDefaults = { planModeAvailable: true, mcpServers: [] as string[], submitKey: "cmd_enter" as const, contextCount: 0, contextPopoverOpen: false, planContextEnabled: false, contextFiles: [] as ContextFile[], isEnhancingPrompt: false, isUtilityConfigured: false, hidePlanMode: false, }; export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInputToolbarProps) { const props = { ...toolbarDefaults, ...rawProps }; const submitShortcut = props.submitKey === "enter" ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT; const toolbarRef = useRef(null); const isCollapsed = useToolbarCollapsed(toolbarRef); const [isExpanded, setIsExpanded] = useState(false); const showCollapsed = isCollapsed && !isExpanded; if (props.minimalToolbar) { return ( ); } const items = buildCollapsibleItems(props); const leftItems = items.filter((i) => i.section === "left" && i.visible !== false); const rightItems = items.filter((i) => i.section === "right" && i.visible !== false); return (
{!props.hidePlanMode && ( )} {!showCollapsed && } {props.onAttachFiles && } {})} trigger={ } sessionId={props.sessionId} planContextEnabled={props.planContextEnabled} contextFiles={props.contextFiles} onToggleFile={props.onToggleFile ?? (() => {})} /> {isCollapsed && ( setIsExpanded((v) => !v)} /> )}
); });