Refactor chat input & properly update state for proposal

This commit is contained in:
Will Chen
2025-04-18 12:32:57 -07:00
parent db7ac39c97
commit b2e3631a29
10 changed files with 140 additions and 48 deletions

View File

@@ -11,6 +11,7 @@ export const selectedChatIdAtom = atom<number | null>(null);
export const isStreamingAtom = atom<boolean>(false); export const isStreamingAtom = atom<boolean>(false);
export const chatInputValueAtom = atom<string>(""); export const chatInputValueAtom = atom<string>("");
export const homeChatInputValueAtom = atom<string>("");
// Atoms for chat list management // Atoms for chat list management
export const chatsAtom = atom<ChatSummary[]>([]); export const chatsAtom = atom<ChatSummary[]>([]);

View File

@@ -0,0 +1,4 @@
import { atom } from "jotai";
import type { Proposal, ProposalResult } from "@/lib/schemas";
export const proposalResultAtom = atom<ProposalResult | null>(null);

View File

@@ -11,12 +11,12 @@ import {
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
import type React from "react"; import type React from "react";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ModelPicker } from "@/components/ModelPicker"; import { ModelPicker } from "@/components/ModelPicker";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { chatInputValueAtom } from "@/atoms/chatAtoms"; import { chatInputValueAtom, chatMessagesAtom } from "@/atoms/chatAtoms";
import { useAtom } from "jotai"; import { useAtom, useSetAtom } from "jotai";
import { useStreamChat } from "@/hooks/useStreamChat"; import { useStreamChat } from "@/hooks/useStreamChat";
import { useChats } from "@/hooks/useChats"; import { useChats } from "@/hooks/useChats";
import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms";
@@ -26,7 +26,8 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useProposal } from "@/hooks/useProposal"; import { useProposal } from "@/hooks/useProposal";
import { Proposal } from "@/lib/schemas"; import { Proposal } from "@/lib/schemas";
import type { Message } from "@/ipc/ipc_types";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
interface ChatInputActionsProps { interface ChatInputActionsProps {
proposal: Proposal; proposal: Proposal;
onApprove: () => void; onApprove: () => void;
@@ -36,12 +37,7 @@ interface ChatInputActionsProps {
isRejecting: boolean; // State for rejecting isRejecting: boolean; // State for rejecting
} }
interface ChatInputProps { export function ChatInput({ chatId }: { chatId?: number }) {
chatId?: number;
onSubmit?: () => void;
}
export function ChatInput({ chatId, onSubmit }: ChatInputProps) {
const [inputValue, setInputValue] = useAtom(chatInputValueAtom); const [inputValue, setInputValue] = useAtom(chatInputValueAtom);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const { settings, updateSettings, isAnyProviderSetup } = useSettings(); const { settings, updateSettings, isAnyProviderSetup } = useSettings();
@@ -51,6 +47,8 @@ export function ChatInput({ chatId, onSubmit }: ChatInputProps) {
const [showError, setShowError] = useState(true); const [showError, setShowError] = useState(true);
const [isApproving, setIsApproving] = useState(false); // State for approving const [isApproving, setIsApproving] = useState(false); // State for approving
const [isRejecting, setIsRejecting] = useState(false); // State for rejecting const [isRejecting, setIsRejecting] = useState(false); // State for rejecting
const [messages, setMessages] = useAtom<Message[]>(chatMessagesAtom);
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
// Use the hook to fetch the proposal // Use the hook to fetch the proposal
const { const {
@@ -80,10 +78,19 @@ export function ChatInput({ chatId, onSubmit }: ChatInputProps) {
} }
}, [error]); }, [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) => { const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
submitHandler(); handleSubmit();
} }
}; };
@@ -96,7 +103,6 @@ export function ChatInput({ chatId, onSubmit }: ChatInputProps) {
setInputValue(""); setInputValue("");
await streamMessage({ prompt: currentInput, chatId }); await streamMessage({ prompt: currentInput, chatId });
}; };
const submitHandler = onSubmit ? onSubmit : handleSubmit;
const handleCancel = () => { const handleCancel = () => {
if (chatId) { if (chatId) {
@@ -133,7 +139,9 @@ export function ChatInput({ chatId, onSubmit }: ChatInputProps) {
setError((err as Error)?.message || "An error occurred while approving"); setError((err as Error)?.message || "An error occurred while approving");
} finally { } finally {
setIsApproving(false); setIsApproving(false);
setIsPreviewOpen(true);
refreshProposal(); refreshProposal();
fetchChatMessages();
} }
}; };
@@ -162,6 +170,7 @@ export function ChatInput({ chatId, onSubmit }: ChatInputProps) {
} finally { } finally {
setIsRejecting(false); setIsRejecting(false);
refreshProposal(); refreshProposal();
fetchChatMessages();
} }
}; };
@@ -236,7 +245,7 @@ export function ChatInput({ chatId, onSubmit }: ChatInputProps) {
</button> </button>
) : ( ) : (
<button <button
onClick={submitHandler} onClick={handleSubmit}
disabled={!inputValue.trim() || !isAnyProviderSetup()} 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" className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
> >

View File

@@ -0,0 +1,85 @@
import { SendIcon, StopCircleIcon, X } from "lucide-react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { ModelPicker } from "@/components/ModelPicker";
import { useSettings } from "@/hooks/useSettings";
import { homeChatInputValueAtom } from "@/atoms/chatAtoms"; // Use a different atom for home input
import { useAtom } from "jotai";
import { useStreamChat } from "@/hooks/useStreamChat";
export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { settings, updateSettings, isAnyProviderSetup } = useSettings();
const { streamMessage, isStreaming, setIsStreaming } = useStreamChat(); // eslint-disable-line @typescript-eslint/no-unused-vars
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]);
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
};
if (!settings) {
return null; // Or loading state
}
return (
<>
<div className="p-4">
<div className="flex flex-col space-y-2 border border-border rounded-lg bg-(--background-lighter) shadow-sm">
<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} // Should ideally reflect if *any* stream is happening
/>
{isStreaming ? (
<button
className="px-2 py-2 mt-1 mr-2 text-(--sidebar-accent-fg) rounded-lg opacity-50 cursor-not-allowed" // Indicate disabled state
title="Cancel generation (unavailable here)"
>
<StopCircleIcon size={20} />
</button>
) : (
<button
onClick={onSubmit}
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"
title="Start new chat"
>
<SendIcon size={20} />
</button>
)}
</div>
<div className="px-2 pb-2">
<ModelPicker
selectedModel={settings.selectedModel}
onModelSelect={(model) =>
updateSettings({ selectedModel: model })
}
/>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,30 +1,26 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import type { Proposal } from "@/lib/schemas"; // Import Proposal type import type { Proposal, ProposalResult } from "@/lib/schemas"; // Import Proposal type
import { proposalResultAtom } from "@/atoms/proposalAtoms";
// Define the structure returned by the IPC call import { useAtom } from "jotai";
interface ProposalResult {
proposal: Proposal;
messageId: number;
}
export function useProposal(chatId?: number | undefined) { export function useProposal(chatId?: number | undefined) {
const [proposalData, setProposalData] = useState<ProposalResult | null>(null); const [proposalResult, setProposalResult] = useAtom(proposalResultAtom);
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fetchProposal = useCallback( const fetchProposal = useCallback(
async (innerChatId?: number) => { async (innerChatId?: number) => {
chatId = chatId ?? innerChatId; chatId = chatId ?? innerChatId;
console.log("fetching proposal for chatId", chatId);
if (chatId === undefined) { if (chatId === undefined) {
setProposalData(null); setProposalResult(null);
setIsLoading(false); setIsLoading(false);
setError(null); setError(null);
return; return;
} }
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
setProposalData(null); // Reset on new fetch setProposalResult(null); // Reset on new fetch
try { try {
// Type assertion might be needed depending on how IpcClient is typed // Type assertion might be needed depending on how IpcClient is typed
const result = (await IpcClient.getInstance().getProposal( const result = (await IpcClient.getInstance().getProposal(
@@ -32,14 +28,14 @@ export function useProposal(chatId?: number | undefined) {
)) as ProposalResult | null; )) as ProposalResult | null;
if (result) { if (result) {
setProposalData(result); setProposalResult(result);
} else { } else {
setProposalData(null); // Explicitly set to null if IPC returns null setProposalResult(null); // Explicitly set to null if IPC returns null
} }
} catch (err: any) { } catch (err: any) {
console.error("Error fetching proposal:", err); console.error("Error fetching proposal:", err);
setError(err.message || "Failed to fetch proposal"); setError(err.message || "Failed to fetch proposal");
setProposalData(null); // Clear proposal data on error setProposalResult(null); // Clear proposal data on error
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -64,8 +60,8 @@ export function useProposal(chatId?: number | undefined) {
); );
return { return {
proposal: proposalData?.proposal ?? null, proposal: proposalResult?.proposal ?? null,
messageId: proposalData?.messageId, messageId: proposalResult?.messageId,
isLoading, isLoading,
error, error,
refreshProposal, // Expose the refresh function refreshProposal, // Expose the refresh function

View File

@@ -47,7 +47,6 @@ export function useStreamChat() {
} }
setError(null); setError(null);
console.log("streaming message - set messages", prompt);
setMessages((currentMessages: Message[]) => { setMessages((currentMessages: Message[]) => {
if (redo) { if (redo) {
let remainingMessages = currentMessages.slice(); let remainingMessages = currentMessages.slice();
@@ -92,10 +91,10 @@ export function useStreamChat() {
if (response.updatedFiles) { if (response.updatedFiles) {
setIsPreviewOpen(true); setIsPreviewOpen(true);
} }
refreshProposal(chatId);
// Keep the same as below // Keep the same as below
setIsStreaming(false); setIsStreaming(false);
refreshProposal(chatId);
refreshChats(); refreshChats();
refreshApp(); refreshApp();
refreshVersions(); refreshVersions();

View File

@@ -285,12 +285,12 @@ export function registerChatStreamHandlers() {
chatId: req.chatId, chatId: req.chatId,
updatedFiles: status.updatedFiles ?? false, updatedFiles: status.updatedFiles ?? false,
} satisfies ChatResponseEnd); } satisfies ChatResponseEnd);
} else {
event.sender.send("chat:response:end", {
chatId: req.chatId,
updatedFiles: false,
} satisfies ChatResponseEnd);
} }
} else {
event.sender.send("chat:response:end", {
chatId: req.chatId,
updatedFiles: false,
} satisfies ChatResponseEnd);
} }
// Return the chat ID for backwards compatibility // Return the chat ID for backwards compatibility

View File

@@ -17,15 +17,9 @@ import type {
Message, Message,
Version, Version,
} from "./ipc_types"; } from "./ipc_types";
import type { Proposal } from "@/lib/schemas"; import type { Proposal, ProposalResult } from "@/lib/schemas";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
// Define the structure returned by getProposal
interface ProposalResult {
proposal: Proposal;
messageId: number;
}
export interface ChatStreamCallbacks { export interface ChatStreamCallbacks {
onUpdate: (messages: Message[]) => void; onUpdate: (messages: Message[]) => void;
onEnd: (response: ChatResponseEnd) => void; onEnd: (response: ChatResponseEnd) => void;

View File

@@ -117,3 +117,8 @@ export interface Proposal {
securityRisks: SecurityRisk[]; securityRisks: SecurityRisk[];
filesChanged: FileChange[]; filesChanged: FileChange[];
} }
export interface ProposalResult {
proposal: Proposal;
messageId: number;
}

View File

@@ -1,19 +1,18 @@
import { useNavigate, useSearch } from "@tanstack/react-router"; import { useNavigate, useSearch } from "@tanstack/react-router";
import { useAtom, useSetAtom } from "jotai"; import { useAtom, useSetAtom } from "jotai";
import { chatInputValueAtom } from "../atoms/chatAtoms"; import { homeChatInputValueAtom } from "../atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { generateCuteAppName } from "@/lib/utils"; import { generateCuteAppName } from "@/lib/utils";
import { useLoadApps } from "@/hooks/useLoadApps"; import { useLoadApps } from "@/hooks/useLoadApps";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { SetupBanner } from "@/components/SetupBanner"; import { SetupBanner } from "@/components/SetupBanner";
import { ChatInput } from "@/components/chat/ChatInput";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms"; import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useStreamChat } from "@/hooks/useStreamChat"; import { useStreamChat } from "@/hooks/useStreamChat";
import { HomeChatInput } from "@/components/chat/HomeChatInput";
export default function HomePage() { export default function HomePage() {
const [inputValue, setInputValue] = useAtom(chatInputValueAtom); const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
const navigate = useNavigate(); const navigate = useNavigate();
const search = useSearch({ from: "/" }); const search = useSearch({ from: "/" });
const setSelectedAppId = useSetAtom(selectedAppIdAtom); const setSelectedAppId = useSetAtom(selectedAppIdAtom);
@@ -87,7 +86,7 @@ export default function HomePage() {
<SetupBanner /> <SetupBanner />
<div className="w-full"> <div className="w-full">
<ChatInput onSubmit={handleSubmit} /> <HomeChatInput onSubmit={handleSubmit} />
<div className="flex flex-wrap gap-4 mt-4"> <div className="flex flex-wrap gap-4 mt-4">
{[ {[