Files
moreminimore-vibe/src/components/chat/ChatInput.tsx
2025-04-21 12:49:54 -07:00

455 lines
14 KiB
TypeScript

import {
SendIcon,
StopCircleIcon,
X,
ChevronDown,
ChevronUp,
AlertTriangle,
AlertOctagon,
FileText,
Check,
Loader2,
} from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ModelPicker } from "@/components/ModelPicker";
import { useSettings } from "@/hooks/useSettings";
import { IpcClient } from "@/ipc/ipc_client";
import { chatInputValueAtom, chatMessagesAtom } from "@/atoms/chatAtoms";
import { useAtom, useSetAtom } from "jotai";
import { useStreamChat } from "@/hooks/useStreamChat";
import { useChats } from "@/hooks/useChats";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useLoadApp } from "@/hooks/useLoadApp";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useProposal } from "@/hooks/useProposal";
import {
CodeProposal,
ActionProposal,
Proposal,
SuggestedAction,
ProposalResult,
} from "@/lib/schemas";
import type { Message } from "@/ipc/ipc_types";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
import { useRunApp } from "@/hooks/useRunApp";
import { AutoApproveSwitch } from "../AutoApproveSwitch";
import { usePostHog } from "posthog-js/react";
export function ChatInput({ chatId }: { chatId?: number }) {
const { capture } = usePostHog();
const [inputValue, setInputValue] = useAtom(chatInputValueAtom);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { settings, updateSettings, isAnyProviderSetup } = useSettings();
const { streamMessage, isStreaming, setIsStreaming, error, setError } =
useStreamChat();
const [selectedAppId] = useAtom(selectedAppIdAtom);
const [showError, setShowError] = useState(true);
const [isApproving, setIsApproving] = useState(false); // State for approving
const [isRejecting, setIsRejecting] = useState(false); // State for rejecting
const [messages, setMessages] = useAtom<Message[]>(chatMessagesAtom);
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
const { refreshAppIframe } = useRunApp();
// Use the hook to fetch the proposal
const {
proposalResult,
isLoading: isProposalLoading,
error: proposalError,
refreshProposal,
} = useProposal(chatId);
const { proposal, chatId: proposalChatId, messageId } = proposalResult ?? {};
const adjustHeight = () => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "0px";
const scrollHeight = textarea.scrollHeight;
textarea.style.height = `${scrollHeight + 4}px`;
}
};
useEffect(() => {
adjustHeight();
}, [inputValue]);
useEffect(() => {
if (error) {
setShowError(true);
}
}, [error]);
const fetchChatMessages = useCallback(async () => {
if (!chatId) {
setMessages([]);
return;
}
const chat = await IpcClient.getInstance().getChat(chatId);
setMessages(chat.messages);
}, [chatId, setMessages]);
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleSubmit = async () => {
if (!inputValue.trim() || isStreaming || !chatId) {
return;
}
const currentInput = inputValue;
setInputValue("");
await streamMessage({ prompt: currentInput, chatId });
capture("chat:submit");
};
const handleCancel = () => {
if (chatId) {
IpcClient.getInstance().cancelChatStream(chatId);
}
setIsStreaming(false);
};
const dismissError = () => {
setShowError(false);
};
const handleApprove = async () => {
if (!chatId || !messageId || isApproving || isRejecting || isStreaming)
return;
console.log(
`Approving proposal for chatId: ${chatId}, messageId: ${messageId}`
);
setIsApproving(true);
capture("chat:approve");
try {
const result = await IpcClient.getInstance().approveProposal({
chatId,
messageId,
});
if (result.success) {
console.log("Proposal approved successfully");
// TODO: Maybe refresh proposal state or show confirmation?
} else {
console.error("Failed to approve proposal:", result.error);
setError(result.error || "Failed to approve proposal");
}
} catch (err) {
console.error("Error approving proposal:", err);
setError((err as Error)?.message || "An error occurred while approving");
} finally {
setIsApproving(false);
setIsPreviewOpen(true);
refreshAppIframe();
// Keep same as handleReject
refreshProposal();
fetchChatMessages();
}
};
const handleReject = async () => {
if (!chatId || !messageId || isApproving || isRejecting || isStreaming)
return;
console.log(
`Rejecting proposal for chatId: ${chatId}, messageId: ${messageId}`
);
setIsRejecting(true);
capture("chat:reject");
try {
const result = await IpcClient.getInstance().rejectProposal({
chatId,
messageId,
});
if (result.success) {
console.log("Proposal rejected successfully");
// TODO: Maybe refresh proposal state or show confirmation?
} else {
console.error("Failed to reject proposal:", result.error);
setError(result.error || "Failed to reject proposal");
}
} catch (err) {
console.error("Error rejecting proposal:", err);
setError((err as Error)?.message || "An error occurred while rejecting");
} finally {
setIsRejecting(false);
// Keep same as handleApprove
refreshProposal();
fetchChatMessages();
}
};
if (!settings) {
return null; // Or loading state
}
return (
<>
{error && showError && (
<div className="relative mt-2 bg-red-50 border border-red-200 rounded-md shadow-sm p-2">
<button
onClick={dismissError}
className="absolute top-1 left-1 p-1 hover:bg-red-100 rounded"
>
<X size={14} className="text-red-500" />
</button>
<div className="px-6 py-1 text-sm">
<div className="text-red-700 text-wrap">{error}</div>
</div>
</div>
)}
{/* Display loading or error state for proposal */}
{isProposalLoading && (
<div className="p-4 text-sm text-muted-foreground">
Loading proposal...
</div>
)}
{proposalError && (
<div className="p-4 text-sm text-red-600">
Error loading proposal: {proposalError}
</div>
)}
<div className="p-4">
<div className="flex flex-col border border-border rounded-lg bg-(--background-lighter) shadow-sm">
{/* Only render ChatInputActions if proposal is loaded */}
{proposal && proposalResult?.chatId === chatId && (
<ChatInputActions
proposal={proposal}
onApprove={handleApprove}
onReject={handleReject}
isApprovable={
!isProposalLoading &&
!!proposal &&
!!messageId &&
!isApproving &&
!isRejecting &&
!isStreaming
}
isApproving={isApproving}
isRejecting={isRejecting}
/>
)}
<div className="flex items-start space-x-2 ">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Ask Dyad to build..."
className="flex-1 p-2 focus:outline-none overflow-y-auto min-h-[40px] max-h-[200px]"
style={{ resize: "none" }}
disabled={isStreaming}
/>
{isStreaming ? (
<button
onClick={handleCancel}
className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg"
title="Cancel generation"
>
<StopCircleIcon size={20} />
</button>
) : (
<button
onClick={handleSubmit}
disabled={!inputValue.trim() || !isAnyProviderSetup()}
className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
>
<SendIcon size={20} />
</button>
)}
</div>
<div className="px-2 pb-2">
<ModelPicker
selectedModel={settings.selectedModel}
onModelSelect={(model) =>
updateSettings({ selectedModel: model })
}
/>
</div>
</div>
</div>
</>
);
}
function mapActionToButton(action: SuggestedAction) {
const { restartApp } = useRunApp();
switch (action.id) {
case "restart-app":
return (
<Button
variant="outline"
size="sm"
className="rounded-xl"
key={action.id}
onClick={restartApp}
>
Restart app
</Button>
);
default:
console.error(`Unsupported action: ${action.id}`);
return (
<Button variant="outline" size="sm" disabled key={action.id}>
Unsupported: {action.id}
</Button>
);
}
}
function ActionProposalActions({ proposal }: { proposal: ActionProposal }) {
return (
<div className="border-b border-border p-2 flex items-center justify-between">
<div className="flex items-center space-x-2">
{proposal.actions.map((action) => mapActionToButton(action))}
</div>
<AutoApproveSwitch />
</div>
);
}
interface ChatInputActionsProps {
proposal: Proposal;
onApprove: () => void;
onReject: () => void;
isApprovable: boolean; // Can be used to enable/disable buttons
isApproving: boolean; // State for approving
isRejecting: boolean; // State for rejecting
}
// Update ChatInputActions to accept props
function ChatInputActions({
proposal,
onApprove,
onReject,
isApprovable,
isApproving,
isRejecting,
}: ChatInputActionsProps) {
const [autoApprove, setAutoApprove] = useState(false);
const [isDetailsVisible, setIsDetailsVisible] = useState(false);
if (proposal.type === "tip-proposal") {
return <div>Tip proposal</div>;
}
if (proposal.type === "action-proposal") {
return <ActionProposalActions proposal={proposal}></ActionProposalActions>;
}
return (
<div className="border-b border-border">
<div className="p-2">
{/* Row 1: Title, Expand Icon, and Security Chip */}
<div className="flex items-center gap-2 mb-1">
<button
className="flex items-center text-left text-sm font-medium hover:bg-muted p-1 rounded justify-start"
onClick={() => setIsDetailsVisible(!isDetailsVisible)}
>
{isDetailsVisible ? (
<ChevronUp size={16} className="mr-1" />
) : (
<ChevronDown size={16} className="mr-1" />
)}
{proposal.title}
</button>
{proposal.securityRisks.length > 0 && (
<span className="bg-red-100 text-red-700 text-xs font-medium px-2 py-0.5 rounded-full">
Security risks found
</span>
)}
</div>
{/* Row 2: Buttons and Toggle */}
<div className="flex items-center justify-start space-x-2">
<Button
className="px-8"
size="sm"
variant="outline"
onClick={onApprove}
disabled={!isApprovable || isApproving || isRejecting}
>
{isApproving ? (
<Loader2 size={16} className="mr-1 animate-spin" />
) : (
<Check size={16} className="mr-1" />
)}
Approve
</Button>
<Button
className="px-8"
size="sm"
variant="outline"
onClick={onReject}
disabled={!isApprovable || isApproving || isRejecting}
>
{isRejecting ? (
<Loader2 size={16} className="mr-1 animate-spin" />
) : (
<X size={16} className="mr-1" />
)}
Reject
</Button>
<div className="flex items-center space-x-1 ml-auto">
<AutoApproveSwitch />
</div>
</div>
</div>
{isDetailsVisible && (
<div className="p-3 border-t border-border bg-muted/50 text-sm">
{!!proposal.securityRisks.length && (
<div className="mb-3">
<h4 className="font-semibold mb-1">Security Risks</h4>
<ul className="space-y-1">
{proposal.securityRisks.map((risk, index) => (
<li key={index} className="flex items-start space-x-2">
{risk.type === "warning" ? (
<AlertTriangle
size={16}
className="text-yellow-500 mt-0.5 flex-shrink-0"
/>
) : (
<AlertOctagon
size={16}
className="text-red-500 mt-0.5 flex-shrink-0"
/>
)}
<div>
<span className="font-medium">{risk.title}:</span>{" "}
<span>{risk.description}</span>
</div>
</li>
))}
</ul>
</div>
)}
<div>
<h4 className="font-semibold mb-1">Files Changed</h4>
<ul className="space-y-1">
{proposal.filesChanged.map((file, index) => (
<li key={index} className="flex items-center space-x-2">
<FileText
size={16}
className="text-muted-foreground flex-shrink-0"
/>
<span title={file.path} className="truncate cursor-default">
{file.name}
</span>
<span className="text-muted-foreground text-xs truncate">
- {file.summary}
</span>
</li>
))}
</ul>
</div>
</div>
)}
</div>
);
}