Files
kandev/apps/web/components/task/chat/chat-input-container.tsx
T
2026-09-07 11:19:09 +01:00

409 lines
14 KiB
TypeScript

"use client";
import { forwardRef, useCallback } from "react";
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 { useChatInputContainer } from "./use-chat-input-container";
import { SessionStoppedBanner } from "./session-stopped-banner";
import { useSessionRecoveryActions } from "@/hooks/domains/session/use-session-recovery-actions";
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 { t } from "@/lib/i18n";
import { shouldHideChatInputForLaunchError, shouldRenderStoppedSessionBanner } from "./types";
// 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<void | boolean>;
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<string, DiffComment[]>;
hasContextComments?: boolean;
submitKey?: "enter" | "cmd_enter";
hasAgentCommands?: boolean;
isFailed?: boolean;
isCompleted?: boolean;
sessionErrorMessage?: string;
needsRecovery?: boolean;
/** The task-owned launch card renders the failed-start recovery. */
launchErrorOwned?: 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;
};
type ContainerState = ReturnType<typeof useChatInputContainer>;
type NormalizedChatInputProps = ChatInputContainerProps & {
isFailed: boolean;
isCompleted: 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,
isCompleted: p.isCompleted ?? 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;
};
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,
hideSessionsDropdown: p.hideSessionsDropdown,
minimalToolbar: p.minimalToolbar,
hideAgentControls: p.hideAgentControls,
hidePlanMode: p.hidePlanMode,
};
}
type StoppedBannerSource = Pick<
ChatInputContainerProps,
"executorUnavailable" | "executorUnavailableReason" | "sessionErrorMessage"
>;
export function buildStoppedBannerProps(p: StoppedBannerSource) {
if (!p.executorUnavailable) {
return p.sessionErrorMessage ? { message: p.sessionErrorMessage } : {};
}
return {
message: p.sessionErrorMessage ?? 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 useChatInputRecoveryActions(taskId: string | null, sessionId: string | null) {
return useSessionRecoveryActions({
taskId: taskId ?? "",
sessionId: sessionId ?? "",
});
}
export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInputContainerProps>(
// eslint-disable-next-line complexity -- top-level component chooses the stopped or editor surface after shared hook setup.
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 recoveryActions = useChatInputRecoveryActions(taskId, sessionId);
const promptEnhancement = useChatPromptEnhancement({
inputRef: s.inputRef,
taskId,
sessionId,
taskTitle,
taskDescription,
});
if (
shouldHideChatInputForLaunchError({
isFailed: p.isFailed,
launchErrorOwned: p.launchErrorOwned,
})
) {
return null;
}
if (
shouldRenderStoppedSessionBanner({
isFailed: p.isFailed,
isCompleted: p.isCompleted,
executorUnavailable,
launchErrorOwned: p.launchErrorOwned,
})
) {
return (
<SessionStoppedBanner
mode={p.isCompleted ? "completed" : "recoverable"}
showDialog={s.showNewSessionDialog}
onShowDialog={s.setShowNewSessionDialog}
taskId={taskId}
sessionId={sessionId}
workspaceId={props.workspaceId}
recoveryActions={recoveryActions}
{...buildStoppedBannerProps(props)}
/>
);
}
return (
<ChatInputBody
containerRef={s.containerRef}
height={s.height}
resizeHandleProps={s.resizeHandleProps}
isStarting={isBusyVisual}
isAgentBusy={isAgentBusy}
hasClarification={s.hasClarification}
showRequestChangesTooltip={p.showRequestChangesTooltip}
hasPendingComments={s.hasPendingComments}
planModeEnabled={props.planModeEnabled}
showFocusHint={s.showFocusHint}
needsRecovery={(props.needsRecovery ?? false) || executorUnavailable}
addFiles={s.addFiles}
contextAreaProps={buildContextAreaProps(s, p)}
promptResultRecovery={
promptEnhancement.promptDelivery.pendingResult ? (
<PromptResultRecovery
pendingResult={promptEnhancement.promptDelivery.pendingResult}
onApply={promptEnhancement.promptDelivery.applyPending}
onCopy={promptEnhancement.promptDelivery.copyPending}
/>
) : null
}
editorAreaProps={buildEditorAreaProps(s, p, {
onEnhancePrompt: promptEnhancement.handleEnhancePrompt,
isEnhancingPrompt: promptEnhancement.isEnhancingPrompt,
isUtilityConfigured: promptEnhancement.isUtilityConfigured,
})}
/>
);
},
);