Files
kandev/apps/web/components/task/chat/chat-input-body.tsx
T

326 lines
10 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
import { TipTapInput } from "./tiptap-input";
import { ChatInputFocusHint } from "./chat-input-focus-hint";
import { ResizeHandle } from "./resize-handle";
import { ChatInputToolbar } from "./chat-input-toolbar";
import { ContextZone } from "./context-items/context-zone";
import type { ContextItem } from "@/lib/types/context";
import type { ContextFile } from "@/lib/state/context-files-store";
export type ChatInputEditorAreaProps = {
inputRef: React.RefObject<import("./tiptap-input").TipTapInputHandle | null>;
value: string;
handleChange: (val: string) => void;
handleSubmitWithReset: () => void;
inputPlaceholder: string;
isDisabled: boolean;
hasClarification: boolean;
planModeEnabled: boolean;
planModeAvailable: boolean;
mcpServers: string[];
submitKey: "enter" | "cmd_enter";
setIsInputFocused: (focused: boolean) => void;
sessionId: string | null;
taskId: string | null;
onAddContextFile?: (file: ContextFile) => void;
onToggleContextFile?: (file: ContextFile) => void;
planContextEnabled: boolean;
handleAgentCommand: (command: string) => void;
addFiles: (files: File[]) => Promise<void>;
fileInputRef: React.RefObject<HTMLInputElement | null>;
showRequestChangesTooltip: boolean;
hideSessionsDropdown?: boolean;
minimalToolbar?: boolean;
hidePlanMode?: boolean;
isAgentBusy: boolean;
onPlanModeChange: (enabled: boolean) => void;
taskTitle?: string;
taskDescription: string;
isSending: boolean;
onCancel: () => void;
contextCount: number;
contextPopoverOpen: boolean;
setContextPopoverOpen: (open: boolean) => void;
contextFiles: ContextFile[];
onImplementPlan?: () => void;
onEnhancePrompt?: () => void;
isEnhancingPrompt?: boolean;
isUtilityConfigured?: boolean;
};
function EditorWithTooltip({
showTooltip,
isEnhancingPrompt,
children,
}: {
showTooltip: boolean;
isEnhancingPrompt?: boolean;
children: React.ReactNode;
}) {
return (
<Tooltip open={showTooltip}>
<TooltipTrigger asChild>
<div
className={cn(
"flex-1 min-h-0 transition-opacity",
isEnhancingPrompt && "opacity-50 pointer-events-none",
)}
>
{children}
</div>
</TooltipTrigger>
<TooltipContent side="top" className="bg-orange-600 text-white border-orange-700">
<p className="font-medium">Write your changes here</p>
</TooltipContent>
</Tooltip>
);
}
function FileInput({
fileInputRef,
addFiles,
}: {
fileInputRef: React.RefObject<HTMLInputElement | null>;
addFiles: (files: File[]) => Promise<void>;
}) {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
void addFiles(Array.from(files));
}
// Reset so re-selecting the same file triggers onChange
e.target.value = "";
},
[addFiles],
);
return (
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleChange}
tabIndex={-1}
/>
);
}
export function ChatInputEditorArea(p: ChatInputEditorAreaProps) {
const { inputRef, value, handleChange, handleSubmitWithReset, inputPlaceholder } = p;
const { isDisabled, hasClarification, planModeEnabled, planModeAvailable, mcpServers } = p;
const { submitKey, setIsInputFocused, sessionId, taskId, planContextEnabled } = p;
const { onAddContextFile, onToggleContextFile, handleAgentCommand, addFiles, fileInputRef } = p;
const { showRequestChangesTooltip, isAgentBusy, onPlanModeChange, taskTitle, taskDescription } =
p;
const { isSending, onCancel, contextCount, contextPopoverOpen, setContextPopoverOpen } = p;
const { contextFiles, onImplementPlan, onEnhancePrompt, isEnhancingPrompt } = p;
const { isUtilityConfigured, hideSessionsDropdown, minimalToolbar, hidePlanMode } = p;
// Exclude auto-added plan context from the count — it's always present in plan mode
// and shouldn't by itself enable the send button.
const userContextCount = planContextEnabled ? Math.max(0, contextCount - 1) : contextCount;
const hasContent = value.trim().length > 0 || userContextCount > 0;
// Block submit while enhancing prompt, but keep editor editable for programmatic updates
const wrappedSubmit = isEnhancingPrompt ? () => {} : handleSubmitWithReset;
const handleAttachFiles = useCallback(() => fileInputRef.current?.click(), [fileInputRef]);
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<EditorWithTooltip
showTooltip={showRequestChangesTooltip}
isEnhancingPrompt={isEnhancingPrompt}
>
<TipTapInput
ref={inputRef}
value={value}
onChange={handleChange}
onSubmit={wrappedSubmit}
placeholder={inputPlaceholder}
disabled={isDisabled || hasClarification}
planModeEnabled={planModeEnabled}
submitKey={submitKey}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
sessionId={sessionId}
taskId={taskId}
onAddContextFile={onAddContextFile}
onToggleContextFile={onToggleContextFile}
planContextEnabled={planContextEnabled}
onAgentCommand={handleAgentCommand}
onImagePaste={addFiles}
onPlanModeChange={onPlanModeChange}
/>
</EditorWithTooltip>
<FileInput fileInputRef={fileInputRef} addFiles={addFiles} />
<ChatInputToolbar
planModeEnabled={planModeEnabled}
planModeAvailable={planModeAvailable}
mcpServers={mcpServers}
onPlanModeChange={onPlanModeChange}
sessionId={sessionId}
taskId={taskId}
taskTitle={taskTitle}
taskDescription={taskDescription}
isAgentBusy={isAgentBusy}
hasContent={hasContent}
isDisabled={isDisabled}
isSending={isSending}
onCancel={onCancel}
onSubmit={wrappedSubmit}
submitKey={submitKey}
contextCount={contextCount}
contextPopoverOpen={contextPopoverOpen}
onContextPopoverOpenChange={setContextPopoverOpen}
planContextEnabled={planContextEnabled}
contextFiles={contextFiles}
onToggleFile={onToggleContextFile}
onImplementPlan={onImplementPlan}
onEnhancePrompt={onEnhancePrompt}
isEnhancingPrompt={isEnhancingPrompt}
isUtilityConfigured={isUtilityConfigured}
onAttachFiles={handleAttachFiles}
hideSessionsDropdown={hideSessionsDropdown}
minimalToolbar={minimalToolbar}
hidePlanMode={hidePlanMode}
/>
</div>
);
}
export type ChatInputContextAreaProps = {
hasContextZone: boolean;
allItems: ContextItem[];
sessionId: string | null;
};
export function ChatInputContextArea({
hasContextZone,
allItems,
sessionId,
}: ChatInputContextAreaProps) {
if (!hasContextZone) return null;
return <ContextZone items={allItems} sessionId={sessionId} />;
}
export type ChatInputBodyProps = {
containerRef: React.RefObject<HTMLDivElement | null>;
height: React.CSSProperties["height"];
resizeHandleProps: { onMouseDown: (e: React.MouseEvent) => void; onDoubleClick: () => void };
isStarting: boolean;
isAgentBusy: boolean;
hasClarification: boolean;
showRequestChangesTooltip: boolean;
hasPendingComments: boolean;
planModeEnabled: boolean;
showFocusHint: boolean;
needsRecovery: boolean;
addFiles: (files: File[]) => Promise<void>;
contextAreaProps: ChatInputContextAreaProps;
editorAreaProps: ChatInputEditorAreaProps;
};
export function ChatInputBody({
containerRef,
height,
resizeHandleProps,
isStarting,
isAgentBusy,
hasClarification,
showRequestChangesTooltip,
hasPendingComments,
planModeEnabled,
showFocusHint,
needsRecovery,
addFiles,
contextAreaProps,
editorAreaProps,
}: ChatInputBodyProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes("Files")) {
setIsDragging(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set false when leaving the container (not entering a child)
const rect = e.currentTarget.getBoundingClientRect();
const { clientX, clientY } = e;
if (
clientX <= rect.left ||
clientX >= rect.right ||
clientY <= rect.top ||
clientY >= rect.bottom
) {
setIsDragging(false);
}
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files).filter((f) => f.size > 0 || f.type !== "");
if (files.length > 0) {
void addFiles(files);
}
},
[addFiles],
);
return (
<div className="relative">
<ResizeHandle
planModeEnabled={planModeEnabled}
isAgentBusy={isAgentBusy}
isStarting={isStarting}
{...resizeHandleProps}
/>
<div
className={cn(
"flex flex-col overflow-hidden border rounded ",
"bg-background border-border",
needsRecovery && "opacity-40 pointer-events-none border-red-500/30",
isStarting && !isAgentBusy && "chat-input-starting",
isAgentBusy && !planModeEnabled && "chat-input-running",
isAgentBusy && planModeEnabled && "chat-input-running-plan",
planModeEnabled && !isAgentBusy && "border-violet-400/50",
hasClarification && "border-sky-400/50",
showRequestChangesTooltip && "animate-pulse border-orange-500",
hasPendingComments && "border-amber-500/50",
isDragging && "border-primary ring-1 ring-primary/30",
)}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<ChatInputFocusHint visible={showFocusHint} />
<ChatInputContextArea {...contextAreaProps} />
<div
ref={containerRef}
style={{ height }}
className="flex flex-col min-h-0 overflow-hidden"
>
<ChatInputEditorArea {...editorAreaProps} />
</div>
</div>
</div>
);
}