Files
kandev/apps/web/components/task/chat/chat-input-body.tsx
T
2026-09-18 16:03:51 +01:00

459 lines
16 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { CompositorPulse } from "@kandev/ui/compositor-pulse";
import { cn } from "@/lib/utils";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
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";
import type { ImagePasteIssue } from "./clipboard-attachments";
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
import {
composerIdentity,
composerInsertionText,
useStablePluginComposerCapability,
} from "@/lib/plugins/composer-capability";
import type { PluginComposerCapability } from "@/lib/plugins/types";
import { useComposerActivity, useComposerFocus } from "./composer-disclosure";
export type ChatInputEditorAreaProps = {
inputRef: React.RefObject<import("./tiptap-input").TipTapInputHandle | null>;
value: string;
handleChange: (val: string) => void;
handleSubmitWithReset: () => void;
inputPlaceholder: string;
isDisabled: boolean;
submitDisabled: boolean;
submitDisabledReason?: string;
hasPendingAttachmentUploads: boolean;
planModeEnabled: boolean;
planModeAvailable: boolean;
mcpServers: string[];
mcpAttachmentHistory?: MCPAttachmentHistory;
submitKey: "enter" | "cmd_enter";
setIsInputFocused: (focused: boolean) => void;
sessionId: string | null;
taskId: string | null;
workspaceId?: string | null;
entityReferencesEnabled?: boolean;
onAddContextFile?: (file: ContextFile) => void;
onToggleContextFile?: (file: ContextFile) => void;
planContextEnabled: boolean;
addFiles: (files: File[], issue?: ImagePasteIssue) => Promise<void>;
fileInputRef: React.RefObject<HTMLInputElement | null>;
showRequestChangesTooltip: boolean;
hideSessionsDropdown?: boolean;
minimalToolbar?: boolean;
hideAgentControls?: boolean;
hidePlanMode?: boolean;
isAgentBusy: boolean;
canCancelAgent?: boolean;
onPlanModeChange: (enabled: boolean) => void;
taskTitle?: string;
taskDescription: string;
isSending: boolean;
onCancel: () => void;
contextCount: number;
contextPopoverOpen: boolean;
setContextPopoverOpen: (open: boolean) => void;
contextFiles: ContextFile[];
editorClassName?: string;
onImplementPlan?: (fresh: boolean) => void;
onEnhancePrompt?: () => void;
isEnhancingPrompt?: boolean;
isUtilityConfigured?: boolean;
};
function EditorWithTooltip({
showTooltip,
isEnhancingPrompt,
className,
children,
}: {
showTooltip: boolean;
isEnhancingPrompt?: boolean;
className?: string;
children: React.ReactNode;
}) {
const { t } = useTranslation();
return (
<Tooltip open={showTooltip}>
<TooltipTrigger asChild>
<div
className={cn(
"flex-1 min-h-0 transition-opacity",
isEnhancingPrompt && "opacity-50 pointer-events-none",
className,
)}
>
{children}
</div>
</TooltipTrigger>
<TooltipContent side="top" className="bg-orange-600 text-white border-orange-700">
<p className="font-medium">{t("task:writeYourChangesHere")}</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}
/>
);
}
function useChatPluginComposer(p: {
inputRef: ChatInputEditorAreaProps["inputRef"];
submitDisabled: boolean;
isEnhancingPrompt: boolean;
hasContent: boolean;
identity: string;
onSubmit: () => void;
}): PluginComposerCapability {
const focus = useComposerFocus(p.inputRef);
return useStablePluginComposerCapability(
{
insertText: (text) => {
const editor = p.inputRef.current;
if (!editor) return false;
const insertion = composerInsertionText(text, editor.getCharBefore());
if (!insertion) return false;
focus();
editor.insertText(insertion, editor.getSelectionStart(), editor.getSelectionEnd());
return true;
},
focus: () => {
if (!p.inputRef.current) return false;
focus();
return true;
},
// Revalidated at call time against the same gate the slot advertises as
// `submittable`, so a capability never submits an empty draft it just
// told the plugin was not submittable.
//
// The draft is read from the editor, not from `hasContent`. A plugin may
// insert a transcript and submit in one callback, and React has not
// re-rendered by then, so the render snapshot still says "empty" while
// the editor already holds the text. `submitDraft` reads its own
// synchronous `valueRef` for the same reason.
submit: async () => {
if (p.isEnhancingPrompt || p.submitDisabled) return false;
const liveDraft = p.inputRef.current?.getValue() ?? "";
if (liveDraft.trim().length === 0 && !p.hasContent) return false;
p.onSubmit();
return true;
},
},
p.identity,
);
}
export function ChatInputEditorArea(p: ChatInputEditorAreaProps) {
useComposerActivity({ busy: Boolean(p.isEnhancingPrompt) });
const { t } = useTranslation("chat");
const { inputRef, value, handleChange, handleSubmitWithReset, inputPlaceholder } = p;
const { isDisabled, planModeEnabled, planModeAvailable, mcpServers } = p;
const { submitKey, setIsInputFocused, sessionId, taskId, planContextEnabled } = p;
const { onAddContextFile, onToggleContextFile, 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, hideAgentControls } = p;
const { 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 || p.submitDisabled ? () => {} : handleSubmitWithReset;
const composerCapability = useChatPluginComposer({
inputRef,
submitDisabled: p.submitDisabled,
isEnhancingPrompt: Boolean(isEnhancingPrompt),
hasContent,
// The container is not remounted when the user switches task or session,
// so this is what tells a captured plugin handle it has been superseded.
identity: composerIdentity(taskId ? "task-chat" : "quick-chat", taskId, sessionId),
onSubmit: handleSubmitWithReset,
});
const submitDisabledReason = p.hasPendingAttachmentUploads
? t("chat:attachmentUploadPendingSubmit")
: p.submitDisabledReason;
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}
className={p.editorClassName}
>
<TipTapInput
ref={inputRef}
value={value}
onChange={handleChange}
onSubmit={wrappedSubmit}
placeholder={inputPlaceholder}
disabled={isDisabled}
planModeEnabled={planModeEnabled}
submitKey={submitKey}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
sessionId={sessionId}
taskId={taskId}
workspaceId={p.workspaceId ?? null}
entityReferencesEnabled={p.entityReferencesEnabled ?? false}
onAddContextFile={onAddContextFile}
onToggleContextFile={onToggleContextFile}
planContextEnabled={planContextEnabled}
onImagePaste={addFiles}
onPlanModeChange={onPlanModeChange}
/>
</EditorWithTooltip>
<FileInput fileInputRef={fileInputRef} addFiles={addFiles} />
<ChatInputToolbar
planModeEnabled={planModeEnabled}
planModeAvailable={planModeAvailable}
mcpServers={mcpServers}
mcpAttachmentHistory={p.mcpAttachmentHistory}
onPlanModeChange={onPlanModeChange}
sessionId={sessionId}
taskId={taskId}
taskTitle={taskTitle}
taskDescription={taskDescription}
isAgentBusy={isAgentBusy}
canCancelAgent={p.canCancelAgent}
hasContent={hasContent}
isDisabled={p.submitDisabled}
submitDisabledReason={submitDisabledReason}
isSending={isSending}
onCancel={onCancel}
onSubmit={wrappedSubmit}
composerCapability={composerCapability}
composerSurface={taskId ? "task-chat" : "quick-chat"}
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}
hideAgentControls={hideAgentControls}
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;
promptResultRecovery?: React.ReactNode;
};
function PromptResultRecoveryArea({ children }: { children?: React.ReactNode }) {
if (!children) return null;
return <div className="mt-2">{children}</div>;
}
/** Glow class for the absolute pulse target outside the overflow-hidden box. */
function chatInputGlowClass(isAgentBusy: boolean, isStarting: boolean): string {
if (isAgentBusy) return "chat-input-glow-running";
if (isStarting) return "chat-input-glow-starting";
return "";
}
function ChatInputGlow({ className }: { className: string }) {
if (!className) return null;
return (
<CompositorPulse
aria-hidden
data-testid="chat-input-glow"
className={className}
minimumOpacity={0.4}
minimumAtEndpoints
/>
);
}
function useChatInputDrop(addFiles: (files: File[]) => Promise<void>) {
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
}, []);
const handleDragEnter = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer.types.includes("Files")) setIsDragging(true);
}, []);
const handleDragLeave = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
const { clientX, clientY } = event;
if (
clientX <= rect.left ||
clientX >= rect.right ||
clientY <= rect.top ||
clientY >= rect.bottom
) {
setIsDragging(false);
}
}, []);
const handleDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
const files = Array.from(event.dataTransfer.files).filter(
(file) => file.size > 0 || file.type !== "",
);
if (files.length > 0) void addFiles(files);
},
[addFiles],
);
return { handleDragEnter, handleDragLeave, handleDragOver, handleDrop, isDragging };
}
function useChatFocusHintVisibility(showFocusHint: boolean): boolean {
const { isMobile } = useResponsiveBreakpoint();
return showFocusHint && !isMobile;
}
export function ChatInputBody({
containerRef,
height,
resizeHandleProps,
isStarting,
isAgentBusy,
hasClarification,
showRequestChangesTooltip,
hasPendingComments,
planModeEnabled,
showFocusHint,
needsRecovery,
addFiles,
contextAreaProps,
editorAreaProps,
promptResultRecovery,
}: ChatInputBodyProps) {
const focusHintVisible = useChatFocusHintVisibility(showFocusHint);
const glowClass = chatInputGlowClass(isAgentBusy, isStarting);
const hasGlow = Boolean(glowClass);
const drop = useChatInputDrop(addFiles);
return (
<div className={cn("relative", { isolate: hasGlow })}>
<ChatInputGlow className={glowClass} />
<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",
drop.isDragging && "border-primary ring-1 ring-primary/30",
)}
onDragOver={drop.handleDragOver}
onDragEnter={drop.handleDragEnter}
onDragLeave={drop.handleDragLeave}
onDrop={drop.handleDrop}
>
<ChatInputFocusHint visible={focusHintVisible} />
<ChatInputContextArea {...contextAreaProps} />
<div
ref={containerRef}
style={{ height }}
data-testid="chat-input-editor-shell"
className="flex flex-col min-h-0 overflow-hidden"
>
<ChatInputEditorArea
{...editorAreaProps}
editorClassName={cn(editorAreaProps.editorClassName, focusHintVisible && "pr-28")}
/>
</div>
</div>
<PromptResultRecoveryArea>{promptResultRecovery}</PromptResultRecoveryArea>
</div>
);
}