488 lines
16 KiB
TypeScript
488 lines
16 KiB
TypeScript
import {
|
|
SendIcon,
|
|
StopCircleIcon,
|
|
X,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
AlertTriangle,
|
|
AlertOctagon,
|
|
FileText,
|
|
Check,
|
|
Loader2,
|
|
Package,
|
|
FileX,
|
|
SendToBack,
|
|
} 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,
|
|
FileChange,
|
|
} 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 posthog = 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 });
|
|
posthog.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);
|
|
posthog.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);
|
|
posthog.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) {
|
|
switch (action.id) {
|
|
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>
|
|
)}
|
|
{proposal.packagesAdded?.length > 0 && (
|
|
<div className="mb-3">
|
|
<h4 className="font-semibold mb-1">Packages Added</h4>
|
|
<ul className="space-y-1">
|
|
{proposal.packagesAdded.map((pkg, index) => (
|
|
<li
|
|
key={index}
|
|
className="flex items-center space-x-2"
|
|
onClick={() => {
|
|
IpcClient.getInstance().openExternalUrl(
|
|
`https://www.npmjs.com/package/${pkg}`
|
|
);
|
|
}}
|
|
>
|
|
<Package
|
|
size={16}
|
|
className="text-muted-foreground flex-shrink-0"
|
|
/>
|
|
<span className="cursor-pointer text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
|
{pkg}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{proposal.filesChanged?.length > 0 && (
|
|
<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">
|
|
{getIconForFileChange(file)}
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function getIconForFileChange(file: FileChange) {
|
|
switch (file.type) {
|
|
case "write":
|
|
return (
|
|
<FileText size={16} className="text-muted-foreground flex-shrink-0" />
|
|
);
|
|
case "rename":
|
|
return (
|
|
<SendToBack size={16} className="text-muted-foreground flex-shrink-0" />
|
|
);
|
|
case "delete":
|
|
return (
|
|
<FileX size={16} className="text-muted-foreground flex-shrink-0" />
|
|
);
|
|
}
|
|
}
|