refactor(frontend): app layout and event handler (#5279)

This commit is contained in:
sp.wack 2024-11-26 23:56:37 +04:00 committed by GitHub
parent 8fad6e6e36
commit f4ef6ab50f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 618 additions and 485 deletions

View File

@ -2,11 +2,11 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { ChatInterface } from "#/components/chat-interface";
import { addUserMessage } from "#/state/chat-slice";
import { SUGGESTIONS } from "#/utils/suggestions";
import * as ChatSlice from "#/state/chat-slice";
import { WsClientProviderStatus } from "#/context/ws-client-provider";
import { ChatInterface } from "#/routes/_oh.app/chat-interface";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const renderChatInterface = (messages: (Message | ErrorMessage)[]) =>
@ -18,7 +18,11 @@ describe("Empty state", () => {
}));
const { useWsClient: useWsClientMock } = vi.hoisted(() => ({
useWsClient: vi.fn(() => ({ send: sendMock, status: WsClientProviderStatus.ACTIVE, isLoadingMessages: false })),
useWsClient: vi.fn(() => ({
send: sendMock,
status: WsClientProviderStatus.ACTIVE,
isLoadingMessages: false,
})),
}));
beforeAll(() => {
@ -84,7 +88,9 @@ describe("Empty state", () => {
async () => {
// this is to test that the message is in the UI before the socket is called
useWsClientMock.mockImplementation(() => ({
send: sendMock, status: WsClientProviderStatus.ACTIVE, isLoadingMessages: false
send: sendMock,
status: WsClientProviderStatus.ACTIVE,
isLoadingMessages: false,
}));
const addUserMessageSpy = vi.spyOn(ChatSlice, "addUserMessage");
const user = userEvent.setup();
@ -112,7 +118,9 @@ describe("Empty state", () => {
"should send the message to the socket only if the runtime is active",
async () => {
useWsClientMock.mockImplementation(() => ({
send: sendMock, status: WsClientProviderStatus.ACTIVE, isLoadingMessages: false
send: sendMock,
status: WsClientProviderStatus.ACTIVE,
isLoadingMessages: false,
}));
const user = userEvent.setup();
const { rerender } = renderWithProviders(<ChatInterface />, {
@ -121,7 +129,6 @@ describe("Empty state", () => {
},
});
const suggestions = screen.getByTestId("suggestions");
const displayedSuggestions = within(suggestions).getAllByRole("button");
@ -129,7 +136,9 @@ describe("Empty state", () => {
expect(sendMock).not.toHaveBeenCalled();
useWsClientMock.mockImplementation(() => ({
send: sendMock, status: WsClientProviderStatus.ACTIVE, isLoadingMessages: false
send: sendMock,
status: WsClientProviderStatus.ACTIVE,
isLoadingMessages: false,
}));
rerender(<ChatInterface />);
@ -330,10 +339,16 @@ describe.skip("ChatInterface", () => {
rerender(<ChatInterface />);
// Verify only one button is shown
const pushToPrButton = screen.getByRole("button", { name: "Push changes to PR" });
const pushToPrButton = screen.getByRole("button", {
name: "Push changes to PR",
});
expect(pushToPrButton).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Push to Branch" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Push & Create PR" })).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Push to Branch" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Push & Create PR" }),
).not.toBeInTheDocument();
});
it("should render feedback actions if there are more than 3 messages", () => {
@ -379,4 +394,4 @@ describe.skip("ChatInterface", () => {
);
it.todo("should render the actions once more after new messages are added");
});
});
});

View File

@ -1,278 +0,0 @@
import { useDispatch, useSelector } from "react-redux";
import React from "react";
import posthog from "posthog-js";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { ChatMessage } from "./chat-message";
import { FeedbackActions } from "./feedback-actions";
import { ImageCarousel } from "./image-carousel";
import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { addUserMessage } from "#/state/chat-slice";
import { RootState } from "#/store";
import AgentState from "#/types/agent-state";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { FeedbackModal } from "./feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import TypingIndicator from "./chat/typing-indicator";
import ConfirmationButtons from "./chat/confirmation-buttons";
import { ErrorMessage } from "./error-message";
import { ContinueButton } from "./continue-button";
import { ScrollToBottomButton } from "./scroll-to-bottom-button";
import { Suggestions } from "./suggestions";
import { SUGGESTIONS } from "#/utils/suggestions";
import BuildIt from "#/icons/build-it.svg?react";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import OpenHands from "#/api/open-hands";
import { downloadWorkspace } from "#/utils/download-workspace";
import { SuggestionItem } from "./suggestion-item";
import { useAuth } from "#/context/auth-context";
const isErrorMessage = (
message: Message | ErrorMessage,
): message is ErrorMessage => "error" in message;
export function ChatInterface() {
const { gitHubToken } = useAuth();
const { send, status, isLoadingMessages } = useWsClient();
const dispatch = useDispatch();
const scrollRef = React.useRef<HTMLDivElement>(null);
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
useScrollToBottom(scrollRef);
const { messages } = useSelector((state: RootState) => state.chat);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"
>("positive");
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
const [isDownloading, setIsDownloading] = React.useState(false);
const [hasPullRequest, setHasPullRequest] = React.useState(false);
React.useEffect(() => {
if (status === WsClientProviderStatus.ACTIVE) {
try {
OpenHands.getRuntimeId().then(({ runtime_id }) => {
// eslint-disable-next-line no-console
console.log(
"Runtime ID: %c%s",
"background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;",
runtime_id,
);
});
} catch (e) {
console.warn("Runtime ID not available in this environment");
}
}
}, [status]);
const handleSendMessage = async (content: string, files: File[]) => {
posthog.capture("user_message_sent", {
current_message_count: messages.length,
});
const promises = files.map((file) => convertImageToBase64(file));
const imageUrls = await Promise.all(promises);
const timestamp = new Date().toISOString();
dispatch(addUserMessage({ content, imageUrls, timestamp }));
send(createChatMessage(content, imageUrls, timestamp));
setMessageToSend(null);
};
const handleStop = () => {
posthog.capture("stop_button_clicked");
send(generateAgentStateChangeEvent(AgentState.STOPPED));
};
const handleSendContinueMsg = () => {
handleSendMessage("Continue", []);
};
const onClickShareFeedbackActionButton = async (
polarity: "positive" | "negative",
) => {
setFeedbackModalIsOpen(true);
setFeedbackPolarity(polarity);
};
const handleDownloadWorkspace = async () => {
setIsDownloading(true);
try {
await downloadWorkspace();
} catch (error) {
// TODO: Handle error
} finally {
setIsDownloading(false);
}
};
return (
<div className="h-full flex flex-col justify-between">
{messages.length === 0 && (
<div className="flex flex-col gap-6 h-full px-4 items-center justify-center">
<div className="flex flex-col items-center p-4 bg-neutral-700 rounded-xl w-full">
<BuildIt width={45} height={54} />
<span className="font-semibold text-[20px] leading-6 -tracking-[0.01em] gap-1">
Let&apos;s start building!
</span>
</div>
<Suggestions
suggestions={Object.entries(SUGGESTIONS.repo)
.slice(0, 4)
.map(([label, value]) => ({
label,
value,
}))}
onSuggestionClick={(value) => {
setMessageToSend(value);
}}
/>
</div>
)}
<div
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
className="flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2"
>
{isLoadingMessages && (
<div className="flex justify-center">
<div className="w-6 h-6 border-2 border-t-[4px] border-primary-500 rounded-full animate-spin" />
</div>
)}
{!isLoadingMessages &&
messages.map((message, index) =>
isErrorMessage(message) ? (
<ErrorMessage
key={index}
id={message.id}
message={message.message}
/>
) : (
<ChatMessage
key={index}
type={message.sender}
message={message.content}
>
{message.imageUrls.length > 0 && (
<ImageCarousel size="small" images={message.imageUrls} />
)}
{messages.length - 1 === index &&
message.sender === "assistant" &&
curAgentState === AgentState.AWAITING_USER_CONFIRMATION && (
<ConfirmationButtons />
)}
</ChatMessage>
),
)}
{(curAgentState === AgentState.AWAITING_USER_INPUT ||
curAgentState === AgentState.FINISHED) && (
<div className="flex flex-col gap-2 mb-2">
{gitHubToken ? (
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
<>
<SuggestionItem
suggestion={{
label: "Push to Branch",
value:
"Please push the changes to a remote branch on GitHub, but do NOT create a pull request.",
}}
onClick={(value) => {
posthog.capture("push_to_branch_button_clicked");
handleSendMessage(value, []);
}}
/>
<SuggestionItem
suggestion={{
label: "Push & Create PR",
value:
"Please push the changes to GitHub and open a pull request.",
}}
onClick={(value) => {
posthog.capture("create_pr_button_clicked");
handleSendMessage(value, []);
setHasPullRequest(true);
}}
/>
</>
) : (
<SuggestionItem
suggestion={{
label: "Push changes to PR",
value:
"Please push the latest changes to the existing pull request.",
}}
onClick={(value) => {
posthog.capture("push_to_pr_button_clicked");
handleSendMessage(value, []);
}}
/>
)}
</div>
) : (
<SuggestionItem
suggestion={{
label: !isDownloading
? "Download .zip"
: "Downloading, please wait...",
value: "Download .zip",
}}
onClick={() => {
posthog.capture("download_workspace_button_clicked");
handleDownloadWorkspace();
}}
/>
)}
</div>
)}
</div>
<div className="flex flex-col gap-[6px] px-4 pb-4">
<div className="flex justify-between relative">
<FeedbackActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
/>
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
{messages.length > 2 &&
curAgentState === AgentState.AWAITING_USER_INPUT && (
<ContinueButton onClick={handleSendContinueMsg} />
)}
{curAgentState === AgentState.RUNNING && <TypingIndicator />}
</div>
{!hitBottom && <ScrollToBottomButton onClick={scrollDomToBottom} />}
</div>
<InteractiveChatBox
onSubmit={handleSendMessage}
onStop={handleStop}
isDisabled={
curAgentState === AgentState.LOADING ||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}
value={messageToSend ?? undefined}
onChange={setMessageToSend}
/>
</div>
<FeedbackModal
isOpen={feedbackModalIsOpen}
onClose={() => setFeedbackModalIsOpen(false)}
polarity={feedbackPolarity}
/>
</div>
);
}

View File

@ -1,193 +0,0 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import toast from "react-hot-toast";
import posthog from "posthog-js";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { ErrorObservation } from "#/types/core/observations";
import { addErrorMessage, addUserMessage } from "#/state/chat-slice";
import {
getCloneRepoCommand,
getGitHubTokenCommand,
} from "#/services/terminal-service";
import {
clearFiles,
clearInitialQuery,
clearSelectedRepository,
setImportedProjectZip,
} from "#/state/initial-query-slice";
import store, { RootState } from "#/store";
import { createChatMessage } from "#/services/chat-service";
import { isGitHubErrorReponse } from "#/api/github";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { setCurrentAgentState } from "#/state/agent-slice";
import AgentState from "#/types/agent-state";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { useAuth } from "#/context/auth-context";
import { useEndSession } from "#/hooks/use-end-session";
import { useUserPrefs } from "#/context/user-prefs-context";
interface ServerError {
error: boolean | string;
message: string;
[key: string]: unknown;
}
const isServerError = (data: object): data is ServerError => "error" in data;
const isErrorObservation = (data: object): data is ErrorObservation =>
"observation" in data && data.observation === "error";
export function EventHandler({ children }: React.PropsWithChildren) {
const { setToken, gitHubToken } = useAuth();
const { settings } = useUserPrefs();
const { events, status, send } = useWsClient();
const statusRef = React.useRef<WsClientProviderStatus | null>(null);
const runtimeActive = status === WsClientProviderStatus.ACTIVE;
const dispatch = useDispatch();
const { files, importedProjectZip, initialQuery } = useSelector(
(state: RootState) => state.initalQuery,
);
const endSession = useEndSession();
// FIXME: Bad practice - should be handled with state
const { selectedRepository } = useSelector(
(state: RootState) => state.initalQuery,
);
const { data: user } = useGitHubUser();
const { mutate: uploadFiles } = useUploadFiles();
const sendInitialQuery = (query: string, base64Files: string[]) => {
const timestamp = new Date().toISOString();
send(createChatMessage(query, base64Files, timestamp));
};
const userId = React.useMemo(() => {
if (user && !isGitHubErrorReponse(user)) return user.id;
return null;
}, [user]);
React.useEffect(() => {
if (!events.length) {
return;
}
const event = events[events.length - 1];
if (event.token && typeof event.token === "string") {
setToken(event.token);
return;
}
if (isServerError(event)) {
if (event.error_code === 401) {
toast.error("Session expired.");
endSession();
return;
}
if (typeof event.error === "string") {
toast.error(event.error);
} else {
toast.error(event.message);
}
return;
}
if (event.type === "error") {
const message: string = `${event.message}`;
if (message.startsWith("Agent reached maximum")) {
// We set the agent state to paused here - if the user clicks resume, it auto updates the max iterations
send(generateAgentStateChangeEvent(AgentState.PAUSED));
}
}
if (isErrorObservation(event)) {
dispatch(
addErrorMessage({
id: event.extras?.error_id,
message: event.message,
}),
);
}
}, [events.length]);
React.useEffect(() => {
if (statusRef.current === status) {
return; // This is a check because of strict mode - if the status did not change, don't do anything
}
statusRef.current = status;
if (status === WsClientProviderStatus.ACTIVE) {
let additionalInfo = "";
if (gitHubToken && selectedRepository) {
send(getCloneRepoCommand(gitHubToken, selectedRepository));
additionalInfo = `Repository ${selectedRepository} has been cloned to /workspace. Please check the /workspace for files.`;
dispatch(clearSelectedRepository()); // reset selected repository; maybe better to move this to '/'?
}
// if there's an uploaded project zip, add it to the chat
else if (importedProjectZip) {
additionalInfo = `Files have been uploaded. Please check the /workspace for files.`;
}
if (initialQuery) {
if (additionalInfo) {
sendInitialQuery(`${initialQuery}\n\n[${additionalInfo}]`, files);
} else {
sendInitialQuery(initialQuery, files);
}
dispatch(clearFiles()); // reset selected files
dispatch(clearInitialQuery()); // reset initial query
}
}
if (status === WsClientProviderStatus.OPENING && initialQuery) {
dispatch(
addUserMessage({
content: initialQuery,
imageUrls: files,
timestamp: new Date().toISOString(),
}),
);
}
if (status === WsClientProviderStatus.STOPPED) {
store.dispatch(setCurrentAgentState(AgentState.STOPPED));
}
}, [status]);
React.useEffect(() => {
if (runtimeActive && userId && gitHubToken) {
// Export if the user valid, this could happen mid-session so it is handled here
send(getGitHubTokenCommand(gitHubToken));
}
}, [userId, gitHubToken, runtimeActive]);
React.useEffect(() => {
if (runtimeActive && importedProjectZip) {
const blob = base64ToBlob(importedProjectZip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
uploadFiles(
{ files: [file] },
{
onError: () => {
toast.error("Failed to upload project files.");
},
},
);
dispatch(setImportedProjectZip(null));
}
}, [runtimeActive, importedProjectZip]);
React.useEffect(() => {
if (settings.LLM_API_KEY) {
posthog.capture("user_activated");
}
}, [settings.LLM_API_KEY]);
return children;
}

View File

@ -1,4 +1,5 @@
import React from "react";
import posthog from "posthog-js";
import {
getSettings,
Settings,
@ -28,6 +29,12 @@ function UserPrefsProvider({ children }: React.PropsWithChildren) {
setSettingsAreUpToDate(checkIfSettingsAreUpToDate());
};
React.useEffect(() => {
if (settings.LLM_API_KEY) {
posthog.capture("user_activated");
}
}, [settings.LLM_API_KEY]);
const value = React.useMemo(
() => ({
settings,

View File

@ -0,0 +1,32 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import OpenHands from "#/api/open-hands";
export const useConversationConfig = () => {
const { status } = useWsClient();
const query = useQuery({
queryKey: ["conversation_config"],
queryFn: OpenHands.getRuntimeId,
enabled: status === WsClientProviderStatus.ACTIVE,
});
React.useEffect(() => {
if (query.data) {
const { runtime_id: runtimeId } = query.data;
// eslint-disable-next-line no-console
console.log(
"Runtime ID: %c%s",
"background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;",
runtimeId,
);
}
}, [query.data]);
return query;
};

View File

@ -0,0 +1,90 @@
import posthog from "posthog-js";
import React from "react";
import { SuggestionItem } from "#/components/suggestion-item";
import { useAuth } from "#/context/auth-context";
import { downloadWorkspace } from "#/utils/download-workspace";
interface ActionSuggestionsProps {
onSuggestionsClick: (value: string) => void;
}
export function ActionSuggestions({
onSuggestionsClick,
}: ActionSuggestionsProps) {
const { gitHubToken } = useAuth();
const [isDownloading, setIsDownloading] = React.useState(false);
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const handleDownloadWorkspace = async () => {
setIsDownloading(true);
try {
await downloadWorkspace();
} catch (error) {
// TODO: Handle error
} finally {
setIsDownloading(false);
}
};
return (
<div className="flex flex-col gap-2 mb-2">
{gitHubToken ? (
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
<>
<SuggestionItem
suggestion={{
label: "Push to Branch",
value:
"Please push the changes to a remote branch on GitHub, but do NOT create a pull request.",
}}
onClick={(value) => {
posthog.capture("push_to_branch_button_clicked");
onSuggestionsClick(value);
}}
/>
<SuggestionItem
suggestion={{
label: "Push & Create PR",
value:
"Please push the changes to GitHub and open a pull request.",
}}
onClick={(value) => {
posthog.capture("create_pr_button_clicked");
onSuggestionsClick(value);
setHasPullRequest(true);
}}
/>
</>
) : (
<SuggestionItem
suggestion={{
label: "Push changes to PR",
value:
"Please push the latest changes to the existing pull request.",
}}
onClick={(value) => {
posthog.capture("push_to_pr_button_clicked");
onSuggestionsClick(value);
}}
/>
)}
</div>
) : (
<SuggestionItem
suggestion={{
label: !isDownloading
? "Download .zip"
: "Downloading, please wait...",
value: "Download .zip",
}}
onClick={() => {
posthog.capture("download_workspace_button_clicked");
handleDownloadWorkspace();
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,144 @@
import { useDispatch, useSelector } from "react-redux";
import React from "react";
import posthog from "posthog-js";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { FeedbackActions } from "../../components/feedback-actions";
import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "../../components/interactive-chat-box";
import { addUserMessage } from "#/state/chat-slice";
import { RootState } from "#/store";
import AgentState from "#/types/agent-state";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { FeedbackModal } from "../../components/feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import TypingIndicator from "../../components/chat/typing-indicator";
import { ContinueButton } from "../../components/continue-button";
import { ScrollToBottomButton } from "../../components/scroll-to-bottom-button";
import { useWsClient } from "#/context/ws-client-provider";
import { Messages } from "./messages";
import { LoadingSpinner } from "./loading-spinner";
import { ChatSuggestions } from "./chat-suggestions";
import { ActionSuggestions } from "./action-suggestions";
export function ChatInterface() {
const { send, isLoadingMessages } = useWsClient();
const dispatch = useDispatch();
const scrollRef = React.useRef<HTMLDivElement>(null);
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
useScrollToBottom(scrollRef);
const { messages } = useSelector((state: RootState) => state.chat);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"
>("positive");
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
const handleSendMessage = async (content: string, files: File[]) => {
posthog.capture("user_message_sent", {
current_message_count: messages.length,
});
const promises = files.map((file) => convertImageToBase64(file));
const imageUrls = await Promise.all(promises);
const timestamp = new Date().toISOString();
dispatch(addUserMessage({ content, imageUrls, timestamp }));
send(createChatMessage(content, imageUrls, timestamp));
setMessageToSend(null);
};
const handleStop = () => {
posthog.capture("stop_button_clicked");
send(generateAgentStateChangeEvent(AgentState.STOPPED));
};
const handleSendContinueMsg = () => {
handleSendMessage("Continue", []);
};
const onClickShareFeedbackActionButton = async (
polarity: "positive" | "negative",
) => {
setFeedbackModalIsOpen(true);
setFeedbackPolarity(polarity);
};
const isWaitingForUserInput =
curAgentState === AgentState.AWAITING_USER_INPUT ||
curAgentState === AgentState.FINISHED;
return (
<div className="h-full flex flex-col justify-between">
{messages.length === 0 && (
<ChatSuggestions onSuggestionsClick={setMessageToSend} />
)}
<div
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
className="flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2"
>
{isLoadingMessages && <LoadingSpinner />}
{!isLoadingMessages && (
<Messages
messages={messages}
isAwaitingUserConfirmation={
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
/>
)}
{isWaitingForUserInput && (
<ActionSuggestions
onSuggestionsClick={(value) => handleSendMessage(value, [])}
/>
)}
</div>
<div className="flex flex-col gap-[6px] px-4 pb-4">
<div className="flex justify-between relative">
<FeedbackActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
/>
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
{messages.length > 2 &&
curAgentState === AgentState.AWAITING_USER_INPUT && (
<ContinueButton onClick={handleSendContinueMsg} />
)}
{curAgentState === AgentState.RUNNING && <TypingIndicator />}
</div>
{!hitBottom && <ScrollToBottomButton onClick={scrollDomToBottom} />}
</div>
<InteractiveChatBox
onSubmit={handleSendMessage}
onStop={handleStop}
isDisabled={
curAgentState === AgentState.LOADING ||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}
value={messageToSend ?? undefined}
onChange={setMessageToSend}
/>
</div>
<FeedbackModal
isOpen={feedbackModalIsOpen}
onClose={() => setFeedbackModalIsOpen(false)}
polarity={feedbackPolarity}
/>
</div>
);
}

View File

@ -0,0 +1,29 @@
import { Suggestions } from "#/components/suggestions";
import BuildIt from "#/icons/build-it.svg?react";
import { SUGGESTIONS } from "#/utils/suggestions";
interface ChatSuggestionsProps {
onSuggestionsClick: (value: string) => void;
}
export function ChatSuggestions({ onSuggestionsClick }: ChatSuggestionsProps) {
return (
<div className="flex flex-col gap-6 h-full px-4 items-center justify-center">
<div className="flex flex-col items-center p-4 bg-neutral-700 rounded-xl w-full">
<BuildIt width={45} height={54} />
<span className="font-semibold text-[20px] leading-6 -tracking-[0.01em] gap-1">
Let&apos;s start building!
</span>
</div>
<Suggestions
suggestions={Object.entries(SUGGESTIONS.repo)
.slice(0, 4)
.map(([label, value]) => ({
label,
value,
}))}
onSuggestionClick={onSuggestionsClick}
/>
</div>
);
}

View File

@ -0,0 +1,12 @@
import React from "react";
import { useWSStatusChange } from "./hooks/use-ws-status-change";
import { useHandleWSEvents } from "./hooks/use-handle-ws-events";
import { useHandleRuntimeActive } from "./hooks/use-handle-runtime-active";
export function EventHandler({ children }: React.PropsWithChildren) {
useWSStatusChange();
useHandleWSEvents();
useHandleRuntimeActive();
return children;
}

View File

@ -0,0 +1,65 @@
import React from "react";
import toast from "react-hot-toast";
import { useDispatch, useSelector } from "react-redux";
import { isGitHubErrorReponse } from "#/api/github";
import { useAuth } from "#/context/auth-context";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { getGitHubTokenCommand } from "#/services/terminal-service";
import { setImportedProjectZip } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { useUploadFiles } from "../../../hooks/mutation/use-upload-files";
import { useGitHubUser } from "../../../hooks/query/use-github-user";
export const useHandleRuntimeActive = () => {
const { gitHubToken } = useAuth();
const { status, send } = useWsClient();
const dispatch = useDispatch();
const { data: user } = useGitHubUser();
const { mutate: uploadFiles } = useUploadFiles();
const runtimeActive = status === WsClientProviderStatus.ACTIVE;
const { importedProjectZip } = useSelector(
(state: RootState) => state.initalQuery,
);
const userId = React.useMemo(() => {
if (user && !isGitHubErrorReponse(user)) return user.id;
return null;
}, [user]);
const handleUploadFiles = (zip: string) => {
const blob = base64ToBlob(zip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
uploadFiles(
{ files: [file] },
{
onError: () => {
toast.error("Failed to upload project files.");
},
},
);
dispatch(setImportedProjectZip(null));
};
React.useEffect(() => {
if (runtimeActive && userId && gitHubToken) {
// Export if the user valid, this could happen mid-session so it is handled here
send(getGitHubTokenCommand(gitHubToken));
}
}, [userId, gitHubToken, runtimeActive]);
React.useEffect(() => {
if (runtimeActive && importedProjectZip) {
handleUploadFiles(importedProjectZip);
}
}, [runtimeActive, importedProjectZip]);
};

View File

@ -0,0 +1,71 @@
import React from "react";
import toast from "react-hot-toast";
import { useDispatch } from "react-redux";
import { useAuth } from "#/context/auth-context";
import { useWsClient } from "#/context/ws-client-provider";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { addErrorMessage } from "#/state/chat-slice";
import AgentState from "#/types/agent-state";
import { ErrorObservation } from "#/types/core/observations";
import { useEndSession } from "../../../hooks/use-end-session";
interface ServerError {
error: boolean | string;
message: string;
[key: string]: unknown;
}
const isServerError = (data: object): data is ServerError => "error" in data;
const isErrorObservation = (data: object): data is ErrorObservation =>
"observation" in data && data.observation === "error";
export const useHandleWSEvents = () => {
const { events, send } = useWsClient();
const { setToken } = useAuth();
const endSession = useEndSession();
const dispatch = useDispatch();
React.useEffect(() => {
if (!events.length) {
return;
}
const event = events[events.length - 1];
if (event.token && typeof event.token === "string") {
setToken(event.token);
return;
}
if (isServerError(event)) {
if (event.error_code === 401) {
toast.error("Session expired.");
endSession();
return;
}
if (typeof event.error === "string") {
toast.error(event.error);
} else {
toast.error(event.message);
}
return;
}
if (event.type === "error") {
const message: string = `${event.message}`;
if (message.startsWith("Agent reached maximum")) {
// We set the agent state to paused here - if the user clicks resume, it auto updates the max iterations
send(generateAgentStateChangeEvent(AgentState.PAUSED));
}
}
if (isErrorObservation(event)) {
dispatch(
addErrorMessage({
id: event.extras?.error_id,
message: event.message,
}),
);
}
}, [events.length]);
};

View File

@ -0,0 +1,97 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { useAuth } from "#/context/auth-context";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { createChatMessage } from "#/services/chat-service";
import { getCloneRepoCommand } from "#/services/terminal-service";
import { setCurrentAgentState } from "#/state/agent-slice";
import { addUserMessage } from "#/state/chat-slice";
import {
clearSelectedRepository,
clearFiles,
clearInitialQuery,
} from "#/state/initial-query-slice";
import { RootState } from "#/store";
import AgentState from "#/types/agent-state";
export const useWSStatusChange = () => {
const { send, status } = useWsClient();
const { gitHubToken } = useAuth();
const dispatch = useDispatch();
const statusRef = React.useRef<WsClientProviderStatus | null>(null);
const { selectedRepository } = useSelector(
(state: RootState) => state.initalQuery,
);
const { files, importedProjectZip, initialQuery } = useSelector(
(state: RootState) => state.initalQuery,
);
const sendInitialQuery = (query: string, base64Files: string[]) => {
const timestamp = new Date().toISOString();
send(createChatMessage(query, base64Files, timestamp));
};
const dispatchCloneRepoCommand = (ghToken: string, repository: string) => {
send(getCloneRepoCommand(ghToken, repository));
dispatch(clearSelectedRepository());
};
const dispatchInitialQuery = (query: string, additionalInfo: string) => {
if (additionalInfo) {
sendInitialQuery(`${query}\n\n[${additionalInfo}]`, files);
} else {
sendInitialQuery(query, files);
}
dispatch(clearFiles()); // reset selected files
dispatch(clearInitialQuery()); // reset initial query
};
const handleOnWSActive = () => {
let additionalInfo = "";
if (gitHubToken && selectedRepository) {
dispatchCloneRepoCommand(gitHubToken, selectedRepository);
additionalInfo = `Repository ${selectedRepository} has been cloned to /workspace. Please check the /workspace for files.`;
} else if (importedProjectZip) {
// if there's an uploaded project zip, add it to the chat
additionalInfo =
"Files have been uploaded. Please check the /workspace for files.";
}
if (initialQuery) {
dispatchInitialQuery(initialQuery, additionalInfo);
}
};
React.useEffect(() => {
if (statusRef.current === status) {
return; // This is a check because of strict mode - if the status did not change, don't do anything
}
statusRef.current = status;
if (status === WsClientProviderStatus.ACTIVE) {
handleOnWSActive();
}
if (status === WsClientProviderStatus.OPENING && initialQuery) {
dispatch(
addUserMessage({
content: initialQuery,
imageUrls: files,
timestamp: new Date().toISOString(),
}),
);
}
if (status === WsClientProviderStatus.STOPPED) {
dispatch(setCurrentAgentState(AgentState.STOPPED));
}
}, [status]);
};

View File

@ -0,0 +1,7 @@
export function LoadingSpinner() {
return (
<div className="flex justify-center">
<div className="w-6 h-6 border-2 border-t-[4px] border-primary-500 rounded-full animate-spin" />
</div>
);
}

View File

@ -0,0 +1,33 @@
import { ChatMessage } from "#/components/chat-message";
import ConfirmationButtons from "#/components/chat/confirmation-buttons";
import { ErrorMessage } from "#/components/error-message";
import { ImageCarousel } from "#/components/image-carousel";
const isErrorMessage = (
message: Message | ErrorMessage,
): message is ErrorMessage => "error" in message;
interface MessagesProps {
messages: (Message | ErrorMessage)[];
isAwaitingUserConfirmation: boolean;
}
export function Messages({
messages,
isAwaitingUserConfirmation,
}: MessagesProps) {
return messages.map((message, index) =>
isErrorMessage(message) ? (
<ErrorMessage key={index} id={message.id} message={message.message} />
) : (
<ChatMessage key={index} type={message.sender} message={message.content}>
{message.imageUrls.length > 0 && (
<ImageCarousel size="small" images={message.imageUrls} />
)}
{messages.length - 1 === index &&
message.sender === "assistant" &&
isAwaitingUserConfirmation && <ConfirmationButtons />}
</ChatMessage>
),
);
}

View File

@ -2,7 +2,7 @@ import { useDisclosure } from "@nextui-org/react";
import React from "react";
import { Outlet } from "@remix-run/react";
import { useDispatch, useSelector } from "react-redux";
import Security from "../components/modals/security/security";
import Security from "#/components/modals/security/security";
import { Controls } from "#/components/controls";
import { RootState } from "#/store";
import { Container } from "#/components/container";
@ -14,18 +14,20 @@ import GlobeIcon from "#/icons/globe.svg?react";
import ListIcon from "#/icons/list-type-number.svg?react";
import { clearJupyter } from "#/state/jupyter-slice";
import { FilesProvider } from "#/context/files";
import { ChatInterface } from "#/components/chat-interface";
import { ChatInterface } from "./chat-interface";
import { WsClientProvider } from "#/context/ws-client-provider";
import { EventHandler } from "#/components/event-handler";
import { EventHandler } from "./event-handler";
import { useLatestRepoCommit } from "#/hooks/query/use-latest-repo-commit";
import { useAuth } from "#/context/auth-context";
import { useUserPrefs } from "#/context/user-prefs-context";
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
function App() {
const { token, gitHubToken } = useAuth();
const { settings } = useUserPrefs();
const dispatch = useDispatch();
useConversationConfig();
const { selectedRepository } = useSelector(
(state: RootState) => state.initalQuery,
@ -41,7 +43,7 @@ function App() {
);
const Terminal = React.useMemo(
() => React.lazy(() => import("../components/terminal/terminal")),
() => React.lazy(() => import("#/components/terminal/terminal")),
[],
);