refactor using Cursor

This commit is contained in:
Steve Androulakis
2025-01-07 13:19:34 -08:00
parent 99b11099af
commit 1b8b9c9906
8 changed files with 534 additions and 283 deletions

View File

@@ -1,69 +1,106 @@
import React from "react";
import React, { memo, useCallback } from "react";
import LLMResponse from "./LLMResponse";
import MessageBubble from "./MessageBubble";
import LoadingIndicator from "./LoadingIndicator";
function safeParse(str) {
try {
return JSON.parse(str);
} catch (err) {
console.error("safeParse error:", err, "Original string:", str);
return {};
}
class ChatErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error("ChatWindow error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="text-red-500 p-4 text-center">
Something went wrong. Please refresh the page.
</div>
);
}
return this.props.children;
}
}
export default function ChatWindow({ conversation, loading, onConfirm }) {
const safeParse = (str) => {
try {
return typeof str === 'string' ? JSON.parse(str) : str;
} catch (err) {
console.error("safeParse error:", err, "Original string:", str);
return str;
}
};
if (!Array.isArray(conversation)) {
console.error("ChatWindow expected conversation to be an array, got:", conversation);
return null;
}
const filtered = conversation.filter((msg) => {
const Message = memo(({ msg, idx, isLastMessage, onConfirm, onContentChange }) => {
const { actor, response } = msg;
if (actor === "user") {
return true;
return <MessageBubble message={{ response }} isUser />;
}
if (actor === "agent") {
const parsed = typeof response === "string" ? safeParse(response) : response;
return true; // Adjust this logic based on your "next" field.
}
return false;
});
return (
<div className="flex-grow flex flex-col">
{/* Main message container */}
<div className="flex-grow flex flex-col justify-end overflow-y-auto space-y-3">
{filtered.map((msg, idx) => {
const { actor, response } = msg;
if (actor === "user") {
return <MessageBubble key={idx} message={{ response }} isUser />;
} else if (actor === "agent") {
const data =
typeof response === "string" ? safeParse(response) : response;
const isLastMessage = idx === filtered.length - 1;
return (
<LLMResponse
key={idx}
const data = safeParse(response);
return (
<LLMResponse
data={data}
onConfirm={onConfirm}
isLastMessage={isLastMessage}
/>
);
}
return null; // Fallback for unsupported actors.
})}
{/* Loading indicator */}
{loading && (
<div className="pt-2 flex justify-center">
<LoadingIndicator />
</div>
)}
</div>
</div>
);
}
onHeightChange={onContentChange}
/>
);
}
return null;
});
Message.displayName = 'Message';
const ChatWindow = memo(({ conversation, loading, onConfirm, onContentChange }) => {
const validateConversation = useCallback((conv) => {
if (!Array.isArray(conv)) {
console.error("ChatWindow expected conversation to be an array, got:", conv);
return [];
}
return conv;
}, []);
const filtered = validateConversation(conversation).filter((msg) => {
const { actor } = msg;
return actor === "user" || actor === "agent";
});
return (
<ChatErrorBoundary>
<div className="flex-grow flex flex-col">
<div className="flex-grow flex flex-col justify-end overflow-y-auto space-y-3">
{filtered.map((msg, idx) => (
<Message
key={`${msg.actor}-${idx}-${typeof msg.response === 'string' ? msg.response : msg.response?.response}`}
msg={msg}
idx={idx}
isLastMessage={idx === filtered.length - 1}
onConfirm={onConfirm}
onContentChange={onContentChange}
/>
))}
{loading && (
<div className="pt-2 flex justify-center">
<LoadingIndicator />
</div>
)}
</div>
</div>
</ChatErrorBoundary>
);
});
ChatWindow.displayName = 'ChatWindow';
export default ChatWindow;

View File

@@ -1,59 +1,65 @@
import React from "react";
import React, { memo } from "react";
export default function ConfirmInline({ data, confirmed, onConfirm }) {
const { args, tool } = data || {};
const ConfirmInline = memo(({ data, confirmed, onConfirm }) => {
const { args, tool } = data || {};
if (confirmed) {
// Once confirmed, show "Running..." state in the same container
return (
<div className="mt-2 p-2 border border-gray-400 dark:border-gray-600 rounded bg-gray-50 dark:bg-gray-800">
<div className="text-sm text-gray-600 dark:text-gray-300">
<div>
<strong>Tool:</strong> {tool ?? "Unknown"}
</div>
{args && (
const renderArgs = () => {
if (!args) return null;
return (
<div className="mt-1">
<strong>Args:</strong>
<pre className="bg-gray-100 dark:bg-gray-700 p-1 rounded text-sm whitespace-pre-wrap">
{JSON.stringify(args, null, 2)}
</pre>
<strong>Args:</strong>
<pre className="bg-gray-100 dark:bg-gray-700 p-1 rounded text-sm whitespace-pre-wrap overflow-x-auto">
{JSON.stringify(args, null, 2)}
</pre>
</div>
)}
</div>
<div className="mt-2 text-green-600 dark:text-green-400 font-medium">
Running {tool}...
</div>
</div>
);
}
);
};
// Not confirmed yet → show confirmation UI
return (
<div className="mt-2 p-2 border border-gray-400 dark:border-gray-600 rounded bg-gray-50 dark:bg-gray-800">
<div className="text-gray-600 dark:text-gray-300">
<div>
Agent is ready to run the tool: <strong>{tool ?? "Unknown"}</strong>
if (confirmed) {
return (
<div className="mt-2 p-2 border border-gray-400 dark:border-gray-600 rounded
bg-gray-50 dark:bg-gray-800 transition-colors duration-200">
<div className="text-sm text-gray-600 dark:text-gray-300">
<div>
<strong>Tool:</strong> {tool ?? "Unknown"}
</div>
{renderArgs()}
</div>
<div className="mt-2 text-green-600 dark:text-green-400 font-medium">
Running {tool}...
</div>
</div>
);
}
return (
<div className="mt-2 p-2 border border-gray-400 dark:border-gray-600 rounded
bg-gray-50 dark:bg-gray-800 transition-colors duration-200">
<div className="text-gray-600 dark:text-gray-300">
<div>
Agent is ready to run the tool: <strong>{tool ?? "Unknown"}</strong>
</div>
{renderArgs()}
<div className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Please confirm to proceed.
</div>
</div>
<div className="text-right mt-2">
<button
onClick={onConfirm}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded
transition-colors duration-200 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-opacity-50"
aria-label={`Confirm running ${tool}`}
>
Confirm
</button>
</div>
</div>
{args && (
<div className="mt-1 text-sm">
With the following parameters
<pre className="bg-gray-100 dark:bg-gray-700 p-1 rounded text-sm whitespace-pre-wrap">
{JSON.stringify(args, null, 2)}
</pre>
</div>
)}
<div className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Please confirm to proceed.
</div>
</div>
<div className="text-right mt-2">
<button
onClick={onConfirm}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded"
>
Confirm
</button>
</div>
</div>
);
}
);
});
ConfirmInline.displayName = 'ConfirmInline';
export default ConfirmInline;

View File

@@ -1,44 +1,60 @@
import React, { useState } from "react";
import React, { memo, useEffect } from "react";
import MessageBubble from "./MessageBubble";
import ConfirmInline from "./ConfirmInline";
export default function LLMResponse({ data, onConfirm, isLastMessage }) {
const [isConfirmed, setIsConfirmed] = useState(false);
const LLMResponse = memo(({ data, onConfirm, isLastMessage, onHeightChange }) => {
const [isConfirmed, setIsConfirmed] = React.useState(false);
const responseRef = React.useRef(null);
const handleConfirm = async () => {
if (onConfirm) await onConfirm();
setIsConfirmed(true);
};
// Notify parent of height changes when confirm UI appears/changes
useEffect(() => {
if (isLastMessage && responseRef.current && onHeightChange) {
onHeightChange();
}
}, [isLastMessage, isConfirmed, onHeightChange]);
// Only requires confirm if data.next === "confirm" AND it's the last message
const requiresConfirm = data.next === "confirm" && isLastMessage;
const handleConfirm = async () => {
try {
if (onConfirm) await onConfirm();
setIsConfirmed(true);
} catch (error) {
console.error('Error confirming action:', error);
}
};
if (typeof data.response === "object") {
data.response = data.response.response;
}
const response = typeof data?.response === 'object'
? data.response.response
: data?.response;
let displayText = (data.response || "").trim();
if (!displayText && requiresConfirm) {
displayText = `Agent is ready to run "${data.tool}". Please confirm.`;
}
const displayText = (response || '').trim();
const requiresConfirm = data.next === "confirm" && isLastMessage;
const defaultText = requiresConfirm
? `Agent is ready to run "${data.tool}". Please confirm.`
: '';
return (
<div className="space-y-2">
<MessageBubble message={{ response: displayText }} />
{requiresConfirm && (
<ConfirmInline
data={data}
confirmed={isConfirmed}
onConfirm={handleConfirm}
/>
)}
{!requiresConfirm && data.tool && data.next === "confirm" && (
<div className="text-sm text-center text-green-600 dark:text-green-400">
<div>
Agent chose tool: <strong>{data.tool ?? "Unknown"}</strong>
</div>
return (
<div ref={responseRef} className="space-y-2">
<MessageBubble
message={{ response: displayText || defaultText }}
/>
{requiresConfirm && (
<ConfirmInline
data={data}
confirmed={isConfirmed}
onConfirm={handleConfirm}
/>
)}
{!requiresConfirm && data.tool && data.next === "confirm" && (
<div className="text-sm text-center text-green-600 dark:text-green-400">
<div>
Agent chose tool: <strong>{data.tool ?? "Unknown"}</strong>
</div>
</div>
)}
</div>
)}
</div>
);
}
);
});
LLMResponse.displayName = 'LLMResponse';
export default LLMResponse;

View File

@@ -1,11 +1,24 @@
import React from "react";
import React, { memo } from "react";
export default function LoadingIndicator() {
return (
<div className="flex items-center justify-center space-x-2 pb-4">
<div className="w-2 h-2 rounded-full bg-blue-600 animate-ping"></div>
<div className="w-2 h-2 rounded-full bg-blue-600 animate-ping delay-100"></div>
<div className="w-2 h-2 rounded-full bg-blue-600 animate-ping delay-200"></div>
</div>
);
}
const LoadingIndicator = memo(() => {
return (
<div
className="flex items-center justify-center space-x-2 pb-4"
role="status"
aria-label="Loading"
>
{[0, 1, 2].map((i) => (
<div
key={i}
className={`w-2 h-2 rounded-full bg-blue-600 animate-ping
${i > 0 ? `delay-${i}00` : ''}`}
/>
))}
<span className="sr-only">Loading...</span>
</div>
);
});
LoadingIndicator.displayName = 'LoadingIndicator';
export default LoadingIndicator;

View File

@@ -1,50 +1,53 @@
import React from "react";
import React, { memo } from "react";
export default function MessageBubble({ message, fallback = "", isUser = false }) {
const bubbleStyle = isUser
? "bg-blue-600 text-white self-end"
: "bg-gray-300 text-gray-900 dark:bg-gray-600 dark:text-gray-100";
const MessageBubble = memo(({ message, fallback = "", isUser = false }) => {
const displayText = message.response?.trim() ? message.response : fallback;
const displayText = message.response?.trim() ? message.response : fallback;
if (displayText.startsWith("###")) {
return null;
}
if (displayText.startsWith("###")) {
return null;
}
const renderTextWithLinks = (text) => {
const urlRegex = /(https?:\/\/[^\s]+)/g;
const parts = text.split(urlRegex);
// Function to detect and render URLs as links
const renderTextWithLinks = (text) => {
const urlRegex = /(https?:\/\/[^\s]+)/g;
const parts = text.split(urlRegex);
return parts.map((part, index) => {
if (urlRegex.test(part)) {
return (
<a
key={index}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 underline"
aria-label={`External link to ${part}`}
>
{part}
</a>
);
}
return part;
});
};
return parts.map((part, index) => {
if (urlRegex.test(part)) {
return (
<a
key={index}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline"
>
{part}
</a>
);
}
return part;
});
};
return (
<div
className={`
inline-block px-4 py-2 mb-1 rounded-lg
${isUser
? "ml-auto bg-blue-100 dark:bg-blue-900 dark:text-white"
: "mr-auto bg-gray-200 dark:bg-gray-700 dark:text-white"
}
break-words max-w-[75%] transition-colors duration-200
`}
role="article"
aria-label={`${isUser ? 'User' : 'Agent'} message`}
>
{renderTextWithLinks(displayText)}
</div>
);
});
return (
<div
className={`inline-block px-4 py-2 mb-1 rounded-lg ${
isUser ? "ml-auto bg-blue-100" : "mr-auto bg-gray-200"
} break-words`}
style={{
whiteSpace: "pre-wrap",
maxWidth: "75%", // or '80%'
}}
>
{renderTextWithLinks(displayText)}
</div>
);
}
MessageBubble.displayName = 'MessageBubble';
export default MessageBubble;

View File

@@ -1,11 +1,20 @@
import React from "react";
import React, { memo } from "react";
export default function NavBar({ title }) {
return (
<header className="fixed top-0 left-0 w-full p-4 bg-white/70 dark:bg-gray-800/70
backdrop-blur-md shadow-md z-10 flex justify-center">
<h1 className="text-xl font-bold font-poppins">{title}</h1>
{/* ...any additional nav items... */}
</header>
);
}
const NavBar = memo(({ title }) => {
return (
<header
className="fixed top-0 left-0 w-full p-4 bg-white/70 dark:bg-gray-800/70
backdrop-blur-md shadow-md z-10 flex justify-center items-center
transition-colors duration-200"
role="banner"
>
<h1 className="text-xl font-bold font-poppins">
{title}
</h1>
</header>
);
});
NavBar.displayName = 'NavBar';
export default NavBar;