refactor(frontend) Refactor and move components (#5290)

This commit is contained in:
sp.wack 2024-12-02 09:47:02 +04:00 committed by GitHub
parent 3e49f0f827
commit b9b6cfd406
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
164 changed files with 1389 additions and 1165 deletions

View File

@ -9,6 +9,7 @@ This is the frontend of the OpenHands project. It is a React application that pr
- Remix SPA Mode (React + Vite + React Router)
- TypeScript
- Redux
- TanStack Query
- Tailwind CSS
- i18next
- React Testing Library
@ -85,7 +86,7 @@ frontend
├── src
│ ├── api # API calls
│ ├── assets
│ ├── components # Reusable components
│ ├── components
│ ├── context # Local state management
│ ├── hooks # Custom hooks
│ ├── i18n # Internationalization
@ -99,6 +100,18 @@ frontend
└── .env.sample # Sample environment variables
```
#### Components
Components are organized into folders based on their **domain**, **feature**, or **shared functionality**.
```sh
components
├── features # Domain-specific components
├── layout
├── modals
└── ui # Shared UI components
```
### Features
- Real-time updates with WebSockets

View File

@ -1,7 +1,7 @@
import { screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { renderWithProviders } from "../../test-utils";
import BrowserPanel from "#/components/browser";
import { BrowserPanel } from "#/components/features/browser/browser";
describe("Browser", () => {
it("renders a message if no screenshotSrc is provided", () => {

View File

@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, test } from "vitest";
import { ChatMessage } from "#/components/chat-message";
import { ChatMessage } from "#/components/features/chat/chat-message";
describe("ChatMessage", () => {
it("should render a user message", () => {

View File

@ -1,7 +1,7 @@
import userEvent from "@testing-library/user-event";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, afterEach, vi, it, expect } from "vitest";
import { ChatInput } from "#/components/chat-input";
import { ChatInput } from "#/components/features/chat/chat-input";
describe("ChatInput", () => {
const onSubmitMock = vi.fn();

View File

@ -6,7 +6,7 @@ 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";
import { ChatInterface } from "#/components/features/chat/chat-interface";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const renderChatInterface = (messages: (Message | ErrorMessage)[]) =>

View File

@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, test, vi } from "vitest";
import { AccountSettingsContextMenu } from "#/components/context-menu/account-settings-context-menu";
import { AccountSettingsContextMenu } from "#/components/features/context-menu/account-settings-context-menu";
describe("AccountSettingsContextMenu", () => {
const user = userEvent.setup();

View File

@ -1,7 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ContextMenuListItem } from "#/components/context-menu/context-menu-list-item";
import { ContextMenuListItem } from "#/components/features/context-menu/context-menu-list-item";
describe("ContextMenuListItem", () => {
it("should render the component with the children", () => {

View File

@ -1,7 +1,7 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FeedbackActions } from "#/components/feedback-actions";
import { FeedbackActions } from "#/components/features/feedback/feedback-actions";
describe("FeedbackActions", () => {
const user = userEvent.setup();

View File

@ -2,7 +2,7 @@ import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { FeedbackForm } from "#/components/feedback-form";
import { FeedbackForm } from "#/components/features/feedback/feedback-form";
describe("FeedbackForm", () => {
const user = userEvent.setup();

View File

@ -1,7 +1,7 @@
import { screen } from "@testing-library/react";
import { renderWithProviders } from "test-utils";
import { describe, afterEach, vi, it, expect } from "vitest";
import ExplorerTree from "#/components/file-explorer/explorer-tree";
import { ExplorerTree } from "#/components/features/file-explorer/explorer-tree";
const FILES = ["file-1-1.ts", "folder-1-2"];

View File

@ -4,8 +4,8 @@ import { renderWithProviders } from "test-utils";
import { describe, it, expect, vi, Mock, afterEach } from "vitest";
import toast from "#/utils/toast";
import AgentState from "#/types/agent-state";
import { FileExplorer } from "#/routes/_oh.app._index/file-explorer/file-explorer";
import OpenHands from "#/api/open-hands";
import { FileExplorer } from "#/components/features/file-explorer/file-explorer";
const toastSpy = vi.spyOn(toast, "error");
const uploadFilesSpy = vi.spyOn(OpenHands, "uploadFiles");

View File

@ -2,7 +2,7 @@ import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { vi, describe, afterEach, it, expect } from "vitest";
import TreeNode from "#/components/file-explorer/tree-node";
import TreeNode from "#/components/features/file-explorer/tree-node";
import OpenHands from "#/api/open-hands";
const getFileSpy = vi.spyOn(OpenHands, "getFile");

View File

@ -1,7 +1,7 @@
import { ImagePreview } from "#/components/features/images/image-preview";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ImagePreview } from "#/components/image-preview";
describe("ImagePreview", () => {
it("should render an image", () => {

View File

@ -1,7 +1,7 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { InteractiveChatBox } from "#/components/interactive-chat-box";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
describe("InteractiveChatBox", () => {
const onSubmitMock = vi.fn();

View File

@ -1,7 +1,7 @@
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, vi, expect } from "vitest";
import BaseModal from "#/components/modals/base-modal/base-modal";
import { BaseModal } from "#/components/shared/modals/base-modal/base-modal";
describe("BaseModal", () => {
it("should render if the modal is open", () => {

View File

@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ModelSelector } from "#/components/modals/settings/model-selector";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
describe("ModelSelector", () => {
const models = {

View File

@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SuggestionItem } from "#/components/suggestion-item";
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
describe("SuggestionItem", () => {
const suggestionItem = { label: "suggestion1", value: "a long text value" };

View File

@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Suggestions } from "#/components/suggestions";
import { Suggestions } from "#/components/features/suggestions/suggestions";
describe("Suggestions", () => {
const firstSuggestion = {

View File

@ -2,7 +2,7 @@ import { act, screen } from "@testing-library/react";
import { renderWithProviders } from "test-utils";
import { vi, describe, afterEach, it, expect } from "vitest";
import { Command, appendInput, appendOutput } from "#/state/command-slice";
import Terminal from "#/components/terminal/terminal";
import Terminal from "#/components/features/terminal/terminal";
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),

View File

@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { UploadImageInput } from "#/components/upload-image-input";
import { UploadImageInput } from "#/components/features/images/upload-image-input";
describe("UploadImageInput", () => {
const user = userEvent.setup();

View File

@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, test, vi, afterEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { UserActions } from "#/components/user-actions";
import { UserActions } from "#/components/features/sidebar/user-actions";
describe("UserActions", () => {
const user = userEvent.setup();

View File

@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { UserAvatar } from "#/components/user-avatar";
import { UserAvatar } from "#/components/features/sidebar/user-avatar";
describe("UserAvatar", () => {
const onClickMock = vi.fn();

View File

@ -1,110 +0,0 @@
import { Tooltip } from "@nextui-org/react";
import React from "react";
import { useSelector } from "react-redux";
import PauseIcon from "#/assets/pause";
import PlayIcon from "#/assets/play";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { RootState } from "#/store";
import AgentState from "#/types/agent-state";
import { useWsClient } from "#/context/ws-client-provider";
const IgnoreTaskStateMap: Record<string, AgentState[]> = {
[AgentState.PAUSED]: [
AgentState.INIT,
AgentState.PAUSED,
AgentState.STOPPED,
AgentState.FINISHED,
AgentState.REJECTED,
AgentState.AWAITING_USER_INPUT,
AgentState.AWAITING_USER_CONFIRMATION,
],
[AgentState.RUNNING]: [
AgentState.INIT,
AgentState.RUNNING,
AgentState.STOPPED,
AgentState.FINISHED,
AgentState.REJECTED,
AgentState.AWAITING_USER_INPUT,
AgentState.AWAITING_USER_CONFIRMATION,
],
[AgentState.STOPPED]: [AgentState.INIT, AgentState.STOPPED],
[AgentState.USER_CONFIRMED]: [AgentState.RUNNING],
[AgentState.USER_REJECTED]: [AgentState.RUNNING],
[AgentState.AWAITING_USER_CONFIRMATION]: [],
};
interface ActionButtonProps {
isDisabled?: boolean;
content: string;
action: AgentState;
handleAction: (action: AgentState) => void;
large?: boolean;
}
function ActionButton({
isDisabled = false,
content,
action,
handleAction,
children,
large = false,
}: React.PropsWithChildren<ActionButtonProps>) {
return (
<Tooltip content={content} closeDelay={100}>
<button
onClick={() => handleAction(action)}
disabled={isDisabled}
className={`
relative overflow-visible cursor-default hover:cursor-pointer group
disabled:cursor-not-allowed
${large ? "rounded-full bg-neutral-800 p-3" : ""}
transition-all duration-300 ease-in-out
`}
type="button"
>
<span className="relative z-10 group-hover:filter group-hover:drop-shadow-[0_0_5px_rgba(255,64,0,0.4)]">
{children}
</span>
<span className="absolute -inset-[5px] border-2 border-red-400/40 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-300 ease-in-out" />
</button>
</Tooltip>
);
}
function AgentControlBar() {
const { send } = useWsClient();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const handleAction = (action: AgentState) => {
if (!IgnoreTaskStateMap[action].includes(curAgentState)) {
send(generateAgentStateChangeEvent(action));
}
};
return (
<div className="flex justify-between items-center gap-20">
<ActionButton
isDisabled={
curAgentState !== AgentState.RUNNING &&
curAgentState !== AgentState.PAUSED
}
content={
curAgentState === AgentState.PAUSED
? "Resume the agent task"
: "Pause the current task"
}
action={
curAgentState === AgentState.PAUSED
? AgentState.RUNNING
: AgentState.PAUSED
}
handleAction={handleAction}
large
>
{curAgentState === AgentState.PAUSED ? <PlayIcon /> : <PauseIcon />}
</ActionButton>
</div>
);
}
export default AgentControlBar;

View File

@ -1,132 +0,0 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import toast from "react-hot-toast";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import AgentState from "#/types/agent-state";
import beep from "#/utils/beep";
enum IndicatorColor {
BLUE = "bg-blue-500",
GREEN = "bg-green-500",
ORANGE = "bg-orange-500",
YELLOW = "bg-yellow-500",
RED = "bg-red-500",
DARK_ORANGE = "bg-orange-800",
}
function AgentStatusBar() {
const { t, i18n } = useTranslation();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curStatusMessage } = useSelector((state: RootState) => state.status);
const AgentStatusMap: {
[k: string]: { message: string; indicator: IndicatorColor };
} = {
[AgentState.INIT]: {
message: t(I18nKey.CHAT_INTERFACE$AGENT_INIT_MESSAGE),
indicator: IndicatorColor.BLUE,
},
[AgentState.RUNNING]: {
message: t(I18nKey.CHAT_INTERFACE$AGENT_RUNNING_MESSAGE),
indicator: IndicatorColor.GREEN,
},
[AgentState.AWAITING_USER_INPUT]: {
message: t(I18nKey.CHAT_INTERFACE$AGENT_AWAITING_USER_INPUT_MESSAGE),
indicator: IndicatorColor.ORANGE,
},
[AgentState.PAUSED]: {
message: t(I18nKey.CHAT_INTERFACE$AGENT_PAUSED_MESSAGE),
indicator: IndicatorColor.YELLOW,
},
[AgentState.LOADING]: {
message: t(I18nKey.CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE),
indicator: IndicatorColor.DARK_ORANGE,
},
[AgentState.STOPPED]: {
message: t(I18nKey.CHAT_INTERFACE$AGENT_STOPPED_MESSAGE),
indicator: IndicatorColor.RED,
},
[AgentState.FINISHED]: {
message: t(I18nKey.CHAT_INTERFACE$AGENT_FINISHED_MESSAGE),
indicator: IndicatorColor.GREEN,
},
[AgentState.REJECTED]: {
message: t(I18nKey.CHAT_INTERFACE$AGENT_REJECTED_MESSAGE),
indicator: IndicatorColor.YELLOW,
},
[AgentState.ERROR]: {
message: t(I18nKey.CHAT_INTERFACE$AGENT_ERROR_MESSAGE),
indicator: IndicatorColor.RED,
},
[AgentState.AWAITING_USER_CONFIRMATION]: {
message: t(
I18nKey.CHAT_INTERFACE$AGENT_AWAITING_USER_CONFIRMATION_MESSAGE,
),
indicator: IndicatorColor.ORANGE,
},
[AgentState.USER_CONFIRMED]: {
message: t(I18nKey.CHAT_INTERFACE$AGENT_ACTION_USER_CONFIRMED_MESSAGE),
indicator: IndicatorColor.GREEN,
},
[AgentState.USER_REJECTED]: {
message: t(I18nKey.CHAT_INTERFACE$AGENT_ACTION_USER_REJECTED_MESSAGE),
indicator: IndicatorColor.RED,
},
};
// TODO: Extend the agent status, e.g.:
// - Agent is typing
// - Agent is initializing
// - Agent is thinking
// - Agent is ready
// - Agent is not available
useEffect(() => {
if (
curAgentState === AgentState.AWAITING_USER_INPUT ||
curAgentState === AgentState.ERROR ||
curAgentState === AgentState.INIT
) {
if (document.cookie.indexOf("audio") !== -1) beep();
}
}, [curAgentState]);
const [statusMessage, setStatusMessage] = React.useState<string>("");
React.useEffect(() => {
let message = curStatusMessage.message || "";
if (curStatusMessage?.id) {
const id = curStatusMessage.id.trim();
if (i18n.exists(id)) {
message = t(curStatusMessage.id.trim()) || message;
}
}
if (curStatusMessage?.type === "error") {
toast.error(message);
return;
}
if (curAgentState === AgentState.LOADING && message.trim()) {
setStatusMessage(message);
} else {
setStatusMessage(AgentStatusMap[curAgentState].message);
}
}, [curStatusMessage.id]);
React.useEffect(() => {
setStatusMessage(AgentStatusMap[curAgentState].message);
}, [curAgentState]);
return (
<div className="flex flex-col items-center">
<div className="flex items-center bg-neutral-800 px-2 py-1 text-gray-400 rounded-[100px] text-sm gap-[6px]">
<div
className={`w-2 h-2 rounded-full animate-pulse ${AgentStatusMap[curAgentState].indicator}`}
/>
<span className="text-sm text-stone-400">{statusMessage}</span>
</div>
</div>
);
}
export default AgentStatusBar;

View File

@ -0,0 +1,64 @@
import { I18nKey } from "#/i18n/declaration";
import AgentState from "#/types/agent-state";
enum IndicatorColor {
BLUE = "bg-blue-500",
GREEN = "bg-green-500",
ORANGE = "bg-orange-500",
YELLOW = "bg-yellow-500",
RED = "bg-red-500",
DARK_ORANGE = "bg-orange-800",
}
export const AGENT_STATUS_MAP: {
[k: string]: { message: string; indicator: IndicatorColor };
} = {
[AgentState.INIT]: {
message: I18nKey.CHAT_INTERFACE$AGENT_INIT_MESSAGE,
indicator: IndicatorColor.BLUE,
},
[AgentState.RUNNING]: {
message: I18nKey.CHAT_INTERFACE$AGENT_RUNNING_MESSAGE,
indicator: IndicatorColor.GREEN,
},
[AgentState.AWAITING_USER_INPUT]: {
message: I18nKey.CHAT_INTERFACE$AGENT_AWAITING_USER_INPUT_MESSAGE,
indicator: IndicatorColor.ORANGE,
},
[AgentState.PAUSED]: {
message: I18nKey.CHAT_INTERFACE$AGENT_PAUSED_MESSAGE,
indicator: IndicatorColor.YELLOW,
},
[AgentState.LOADING]: {
message: I18nKey.CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE,
indicator: IndicatorColor.DARK_ORANGE,
},
[AgentState.STOPPED]: {
message: I18nKey.CHAT_INTERFACE$AGENT_STOPPED_MESSAGE,
indicator: IndicatorColor.RED,
},
[AgentState.FINISHED]: {
message: I18nKey.CHAT_INTERFACE$AGENT_FINISHED_MESSAGE,
indicator: IndicatorColor.GREEN,
},
[AgentState.REJECTED]: {
message: I18nKey.CHAT_INTERFACE$AGENT_REJECTED_MESSAGE,
indicator: IndicatorColor.YELLOW,
},
[AgentState.ERROR]: {
message: I18nKey.CHAT_INTERFACE$AGENT_ERROR_MESSAGE,
indicator: IndicatorColor.RED,
},
[AgentState.AWAITING_USER_CONFIRMATION]: {
message: I18nKey.CHAT_INTERFACE$AGENT_AWAITING_USER_CONFIRMATION_MESSAGE,
indicator: IndicatorColor.ORANGE,
},
[AgentState.USER_CONFIRMED]: {
message: I18nKey.CHAT_INTERFACE$AGENT_ACTION_USER_CONFIRMED_MESSAGE,
indicator: IndicatorColor.GREEN,
},
[AgentState.USER_REJECTED]: {
message: I18nKey.CHAT_INTERFACE$AGENT_ACTION_USER_REJECTED_MESSAGE,
indicator: IndicatorColor.RED,
},
};

View File

@ -1,64 +0,0 @@
import { Tooltip } from "@nextui-org/react";
import { useTranslation } from "react-i18next";
import ConfirmIcon from "#/assets/confirm";
import RejectIcon from "#/assets/reject";
import { I18nKey } from "#/i18n/declaration";
import AgentState from "#/types/agent-state";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { useWsClient } from "#/context/ws-client-provider";
interface ActionTooltipProps {
type: "confirm" | "reject";
onClick: () => void;
}
function ActionTooltip({ type, onClick }: ActionTooltipProps) {
const { t } = useTranslation();
const content =
type === "confirm"
? t(I18nKey.CHAT_INTERFACE$USER_CONFIRMED)
: t(I18nKey.CHAT_INTERFACE$USER_REJECTED);
return (
<Tooltip content={content} closeDelay={100}>
<button
data-testid={`action-${type}-button`}
type="button"
aria-label={type === "confirm" ? "Confirm action" : "Reject action"}
className="bg-neutral-700 rounded-full p-1 hover:bg-neutral-800"
onClick={onClick}
>
{type === "confirm" ? <ConfirmIcon /> : <RejectIcon />}
</button>
</Tooltip>
);
}
function ConfirmationButtons() {
const { t } = useTranslation();
const { send } = useWsClient();
const handleStateChange = (state: AgentState) => {
const event = generateAgentStateChangeEvent(state);
send(event);
};
return (
<div className="flex justify-between items-center pt-4">
<p>{t(I18nKey.CHAT_INTERFACE$USER_ASK_CONFIRMATION)}</p>
<div className="flex items-center gap-3">
<ActionTooltip
type="confirm"
onClick={() => handleStateChange(AgentState.USER_CONFIRMED)}
/>
<ActionTooltip
type="reject"
onClick={() => handleStateChange(AgentState.USER_REJECTED)}
/>
</div>
</div>
);
}
export default ConfirmationButtons;

View File

@ -0,0 +1,20 @@
import { DiJavascript } from "react-icons/di";
import {
FaCss3,
FaHtml5,
FaList,
FaMarkdown,
FaNpm,
FaPython,
} from "react-icons/fa";
export const EXTENSION_ICON_MAP: Record<string, JSX.Element> = {
js: <DiJavascript />,
ts: <DiJavascript />,
py: <FaPython />,
css: <FaCss3 />,
json: <FaList />,
npmignore: <FaNpm />,
html: <FaHtml5 />,
md: <FaMarkdown />,
};

View File

@ -1,10 +1,10 @@
import { ModalBackdrop } from "./modals/modal-backdrop";
import ModalBody from "./modals/modal-body";
import ModalButton from "./buttons/modal-button";
import { ModalButton } from "#/components/shared/buttons/modal-button";
import {
BaseModalTitle,
BaseModalDescription,
} from "./modals/confirmation-modals/base-modal";
} from "#/components/shared/modals/confirmation-modals/base-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
interface AnalyticsConsentFormModalProps {

View File

@ -0,0 +1,14 @@
interface BrowserSnaphsotProps {
src: string;
}
export function BrowserSnapshot({ src }: BrowserSnaphsotProps) {
return (
<img
src={src}
style={{ objectFit: "contain", width: "100%", height: "auto" }}
className="rounded-xl"
alt="Browser Screenshot"
/>
);
}

View File

@ -1,12 +1,9 @@
import { useTranslation } from "react-i18next";
import { IoIosGlobe } from "react-icons/io";
import { useSelector } from "react-redux";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import { BrowserSnapshot } from "./browser-snapshot";
import { EmptyBrowserMessage } from "./empty-browser-message";
function BrowserPanel() {
const { t } = useTranslation();
export function BrowserPanel() {
const { url, screenshotSrc } = useSelector(
(state: RootState) => state.browser,
);
@ -23,21 +20,11 @@ function BrowserPanel() {
</div>
<div className="overflow-y-auto grow scrollbar-hide rounded-xl">
{screenshotSrc ? (
<img
src={imgSrc}
style={{ objectFit: "contain", width: "100%", height: "auto" }}
className="rounded-xl"
alt="Browser Screenshot"
/>
<BrowserSnapshot src={imgSrc} />
) : (
<div className="flex flex-col items-center h-full justify-center">
<IoIosGlobe size={100} />
{t(I18nKey.BROWSER$EMPTY_MESSAGE)}
</div>
<EmptyBrowserMessage />
)}
</div>
</div>
);
}
export default BrowserPanel;

View File

@ -0,0 +1,14 @@
import { useTranslation } from "react-i18next";
import { IoIosGlobe } from "react-icons/io";
import { I18nKey } from "#/i18n/declaration";
export function EmptyBrowserMessage() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center h-full justify-center">
<IoIosGlobe size={100} />
{t(I18nKey.BROWSER$EMPTY_MESSAGE)}
</div>
);
}

View File

@ -1,6 +1,6 @@
import posthog from "posthog-js";
import React from "react";
import { SuggestionItem } from "#/components/suggestion-item";
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
import { useAuth } from "#/context/auth-context";
import { downloadWorkspace } from "#/utils/download-workspace";

View File

@ -1,7 +1,8 @@
import React from "react";
import TextareaAutosize from "react-textarea-autosize";
import ArrowSendIcon from "#/icons/arrow-send.svg?react";
import { cn } from "#/utils/utils";
import { SubmitButton } from "#/components/shared/buttons/submit-button";
import { StopButton } from "#/components/shared/buttons/stop-button";
interface ChatInputProps {
name?: string;
@ -132,27 +133,10 @@ export function ChatInput({
{showButton && (
<div className={buttonClassName}>
{button === "submit" && (
<button
aria-label="Send"
disabled={disabled}
onClick={handleSubmitMessage}
type="submit"
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center"
>
<ArrowSendIcon />
</button>
<SubmitButton isDisabled={disabled} onClick={handleSubmitMessage} />
)}
{button === "stop" && (
<button
data-testid="stop-button"
aria-label="Stop"
disabled={disabled}
onClick={onStop}
type="button"
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center"
>
<div className="w-[10px] h-[10px] bg-white" />
</button>
<StopButton isDisabled={disabled} onClick={onStop} />
)}
</div>
)}

View File

@ -2,23 +2,23 @@ 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 { FeedbackActions } from "../feedback/feedback-actions";
import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "../../components/interactive-chat-box";
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 "../../components/feedback-modal";
import { FeedbackModal } from "../feedback/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 { TypingIndicator } from "./typing-indicator";
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";
import { ContinueButton } from "#/components/shared/buttons/continue-button";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
export function ChatInterface() {
const { send, isLoadingMessages } = useWsClient();
@ -81,7 +81,11 @@ export function ChatInterface() {
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 && (
<div className="flex justify-center">
<LoadingSpinner size="small" />
</div>
)}
{!isLoadingMessages && (
<Messages

View File

@ -1,11 +1,10 @@
import React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import CheckmarkIcon from "#/icons/checkmark.svg?react";
import CopyIcon from "#/icons/copy.svg?react";
import { code } from "./markdown/code";
import { code } from "../markdown/code";
import { cn } from "#/utils/utils";
import { ul, ol } from "./markdown/list";
import { ul, ol } from "../markdown/list";
import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button";
interface ChatMessageProps {
type: "user" | "assistant";
@ -51,23 +50,12 @@ export function ChatMessage({
type === "assistant" && "pb-4 max-w-full bg-tranparent",
)}
>
<button
hidden={!isHovering}
disabled={isCopy}
data-testid="copy-to-clipboard"
type="button"
<CopyToClipboardButton
isHidden={!isHovering}
isDisabled={isCopy}
onClick={handleCopyToClipboard}
className={cn(
"bg-neutral-700 border border-neutral-600 rounded p-1",
"absolute top-1 right-1",
)}
>
{!isCopy ? (
<CopyIcon width={15} height={15} />
) : (
<CheckmarkIcon width={15} height={15} />
)}
</button>
mode={isCopy ? "copied" : "copy"}
/>
<Markdown
className="text-sm overflow-auto"
components={{

View File

@ -1,4 +1,4 @@
import { Suggestions } from "#/components/suggestions";
import { Suggestions } from "#/components/features/suggestions/suggestions";
import BuildIt from "#/icons/build-it.svg?react";
import { SUGGESTIONS } from "#/utils/suggestions";

View File

@ -1,8 +1,8 @@
import React from "react";
import { UploadImageInput } from "./upload-image-input";
import { ChatInput } from "./chat-input";
import { cn } from "#/utils/utils";
import { ImageCarousel } from "./image-carousel";
import { ImageCarousel } from "../images/image-carousel";
import { UploadImageInput } from "../images/upload-image-input";
interface InteractiveChatBoxProps {
isDisabled?: boolean;

View File

@ -1,7 +1,7 @@
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";
import { ChatMessage } from "#/components/features/chat/chat-message";
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
import { ImageCarousel } from "../images/image-carousel";
import { ErrorMessage } from "./error-message";
const isErrorMessage = (
message: Message | ErrorMessage,

View File

@ -1,6 +1,4 @@
import React from "react";
function TypingIndicator(): React.ReactElement {
export function TypingIndicator() {
return (
<div className="flex items-center space-x-1.5 bg-neutral-700 px-3 py-1.5 rounded-full">
<span
@ -18,5 +16,3 @@ function TypingIndicator(): React.ReactElement {
</div>
);
}
export default TypingIndicator;

View File

@ -0,0 +1,44 @@
import { useSelector } from "react-redux";
import PauseIcon from "#/assets/pause";
import PlayIcon from "#/assets/play";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { RootState } from "#/store";
import AgentState from "#/types/agent-state";
import { useWsClient } from "#/context/ws-client-provider";
import { IGNORE_TASK_STATE_MAP } from "#/ignore-task-state-map.constant";
import { ActionButton } from "#/components/shared/buttons/action-button";
export function AgentControlBar() {
const { send } = useWsClient();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const handleAction = (action: AgentState) => {
if (!IGNORE_TASK_STATE_MAP[action].includes(curAgentState)) {
send(generateAgentStateChangeEvent(action));
}
};
return (
<div className="flex justify-between items-center gap-20">
<ActionButton
isDisabled={
curAgentState !== AgentState.RUNNING &&
curAgentState !== AgentState.PAUSED
}
content={
curAgentState === AgentState.PAUSED
? "Resume the agent task"
: "Pause the current task"
}
action={
curAgentState === AgentState.PAUSED
? AgentState.RUNNING
: AgentState.PAUSED
}
handleAction={handleAction}
>
{curAgentState === AgentState.PAUSED ? <PlayIcon /> : <PauseIcon />}
</ActionButton>
</div>
);
}

View File

@ -0,0 +1,53 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import toast from "react-hot-toast";
import { RootState } from "#/store";
import AgentState from "#/types/agent-state";
import { AGENT_STATUS_MAP } from "../../agent-status-map.constant";
export function AgentStatusBar() {
const { t, i18n } = useTranslation();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curStatusMessage } = useSelector((state: RootState) => state.status);
const [statusMessage, setStatusMessage] = React.useState<string>("");
const updateStatusMessage = () => {
let message = curStatusMessage.message || "";
if (curStatusMessage?.id) {
const id = curStatusMessage.id.trim();
if (i18n.exists(id)) {
message = t(curStatusMessage.id.trim()) || message;
}
}
if (curStatusMessage?.type === "error") {
toast.error(message);
return;
}
if (curAgentState === AgentState.LOADING && message.trim()) {
setStatusMessage(message);
} else {
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
}
};
React.useEffect(() => {
updateStatusMessage();
}, [curStatusMessage.id]);
React.useEffect(() => {
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
}, [curAgentState]);
return (
<div className="flex flex-col items-center">
<div className="flex items-center bg-neutral-800 px-2 py-1 text-gray-400 rounded-[100px] text-sm gap-[6px]">
<div
className={`w-2 h-2 rounded-full animate-pulse ${AGENT_STATUS_MAP[curAgentState].indicator}`}
/>
<span className="text-sm text-stone-400">{t(statusMessage)}</span>
</div>
</div>
);
}

View File

@ -1,11 +1,11 @@
import { IoLockClosed } from "react-icons/io5";
import React from "react";
import { useSelector } from "react-redux";
import AgentControlBar from "./agent-control-bar";
import AgentStatusBar from "./agent-status-bar";
import { ProjectMenuCard } from "./project-menu/ProjectMenuCard";
import { AgentControlBar } from "./agent-control-bar";
import { AgentStatusBar } from "./agent-status-bar";
import { ProjectMenuCard } from "../project-menu/ProjectMenuCard";
import { useAuth } from "#/context/auth-context";
import { RootState } from "#/store";
import { SecurityLock } from "./security-lock";
interface ControlsProps {
setSecurityOpen: (isOpen: boolean) => void;
@ -42,13 +42,7 @@ export function Controls({
<AgentStatusBar />
{showSecurityLock && (
<div
className="cursor-pointer hover:opacity-80 transition-all"
style={{ marginRight: "8px" }}
onClick={() => setSecurityOpen(true)}
>
<IoLockClosed size={20} />
</div>
<SecurityLock onClick={() => setSecurityOpen(true)} />
)}
</div>

View File

@ -0,0 +1,17 @@
import { IoLockClosed } from "react-icons/io5";
interface SecurityLockProps {
onClick: () => void;
}
export function SecurityLock({ onClick }: SecurityLockProps) {
return (
<div
className="cursor-pointer hover:opacity-80 transition-all"
style={{ marginRight: "8px" }}
onClick={onClick}
>
<IoLockClosed size={20} />
</div>
);
}

View File

@ -1,33 +1,4 @@
import { cn } from "@nextui-org/react";
import { HTMLAttributes } from "react";
interface EditorActionButtonProps {
onClick: () => void;
disabled: boolean;
className: HTMLAttributes<HTMLButtonElement>["className"];
}
function EditorActionButton({
onClick,
disabled,
className,
children,
}: React.PropsWithChildren<EditorActionButtonProps>) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"text-sm py-0.5 rounded w-20",
"hover:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed",
className,
)}
>
{children}
</button>
);
}
import { EditorActionButton } from "#/components/shared/buttons/editor-action-button";
interface EditorActionsProps {
onSave: () => void;

View File

@ -1,28 +1,6 @@
import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
interface FeedbackActionButtonProps {
testId?: string;
onClick: () => void;
icon: React.ReactNode;
}
function FeedbackActionButton({
testId,
onClick,
icon,
}: FeedbackActionButtonProps) {
return (
<button
type="button"
data-testid={testId}
onClick={onClick}
className="p-1 bg-neutral-700 border border-neutral-600 rounded hover:bg-neutral-500"
>
{icon}
</button>
);
}
import { FeedbackActionButton } from "#/components/shared/buttons/feedback-action-button";
interface FeedbackActionsProps {
onPositiveFeedback: () => void;

View File

@ -1,8 +1,8 @@
import React from "react";
import hotToast from "react-hot-toast";
import ModalButton from "./buttons/modal-button";
import { Feedback } from "#/api/open-hands.types";
import { useSubmitFeedback } from "#/hooks/mutation/use-submit-feedback";
import { ModalButton } from "#/components/shared/buttons/modal-button";
const FEEDBACK_VERSION = "1.0";
const VIEWER_PAGE = "https://www.all-hands.dev/share";

View File

@ -1,10 +1,10 @@
import { FeedbackForm } from "./feedback-form";
import {
BaseModalTitle,
BaseModalDescription,
} from "./modals/confirmation-modals/base-modal";
import { ModalBackdrop } from "./modals/modal-backdrop";
import ModalBody from "./modals/modal-body";
} from "#/components/shared/modals/confirmation-modals/base-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { FeedbackForm } from "./feedback-form";
interface FeedbackModalProps {
onClose: () => void;

View File

@ -7,7 +7,10 @@ interface ExplorerTreeProps {
defaultOpen?: boolean;
}
function ExplorerTree({ files, defaultOpen = false }: ExplorerTreeProps) {
export function ExplorerTree({
files,
defaultOpen = false,
}: ExplorerTreeProps) {
const { t } = useTranslation();
if (!files?.length) {
const message = !files
@ -23,5 +26,3 @@ function ExplorerTree({ files, defaultOpen = false }: ExplorerTreeProps) {
</div>
);
}
export default ExplorerTree;

View File

@ -1,7 +1,7 @@
import { RefreshIconButton } from "#/components/shared/buttons/refresh-icon-button";
import { ToggleWorkspaceIconButton } from "#/components/shared/buttons/toggle-workspace-icon-button";
import { UploadIconButton } from "#/components/shared/buttons/upload-icon-button";
import { cn } from "#/utils/utils";
import { RefreshIconButton } from "./buttons/refresh-icon-button";
import { ToggleWorkspaceIconButton } from "./buttons/toggle-workspace-icon-button";
import { UploadIconButton } from "./buttons/upload-icon-button";
interface ExplorerActionsProps {
onRefresh: () => void;

View File

@ -2,7 +2,7 @@ import React from "react";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import AgentState from "#/types/agent-state";
import ExplorerTree from "../../../components/file-explorer/explorer-tree";
import { ExplorerTree } from "#/components/features/file-explorer/explorer-tree";
import toast from "#/utils/toast";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
@ -10,10 +10,10 @@ import { useListFiles } from "#/hooks/query/use-list-files";
import { FileUploadSuccessResponse } from "#/api/open-hands.types";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { cn } from "#/utils/utils";
import { OpenVSCodeButton } from "./buttons/open-vscode-button";
import { Dropzone } from "./dropzone";
import { FileExplorerHeader } from "./file-explorer-header";
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
import { OpenVSCodeButton } from "#/components/shared/buttons/open-vscode-button";
interface FileExplorerProps {
isOpen: boolean;

View File

@ -0,0 +1,12 @@
import { FaFile } from "react-icons/fa";
import { getExtension } from "#/utils/utils";
import { EXTENSION_ICON_MAP } from "../../extension-icon-map.constant";
interface FileIconProps {
filename: string;
}
export function FileIcon({ filename }: FileIconProps) {
const extension = getExtension(filename);
return EXTENSION_ICON_MAP[extension] || <FaFile />;
}

View File

@ -0,0 +1,20 @@
import { FolderIcon } from "./folder-icon";
import { FileIcon } from "./file-icon";
interface FilenameProps {
name: string;
type: "folder" | "file";
isOpen: boolean;
}
export function Filename({ name, type, isOpen }: FilenameProps) {
return (
<div className="cursor-pointer text-nowrap rounded-[5px] p-1 nowrap flex items-center gap-2 aria-selected:bg-neutral-600 aria-selected:text-white hover:text-white">
<div className="flex-shrink-0">
{type === "folder" && <FolderIcon isOpen={isOpen} />}
{type === "file" && <FileIcon filename={name} />}
</div>
<div className="flex-grow">{name}</div>
</div>
);
}

View File

@ -4,12 +4,10 @@ interface FolderIconProps {
isOpen: boolean;
}
function FolderIcon({ isOpen }: FolderIconProps): JSX.Element {
export function FolderIcon({ isOpen }: FolderIconProps): JSX.Element {
return isOpen ? (
<FaFolderOpen color="D9D3D0" className="icon" />
) : (
<FaFolder color="D9D3D0" className="icon" />
);
}
export default FolderIcon;

View File

@ -1,28 +1,10 @@
import React from "react";
import FolderIcon from "../folder-icon";
import FileIcon from "../file-icons";
import { useFiles } from "#/context/files";
import { cn } from "#/utils/utils";
import { useListFiles } from "#/hooks/query/use-list-files";
import { useListFile } from "#/hooks/query/use-list-file";
interface TitleProps {
name: string;
type: "folder" | "file";
isOpen: boolean;
}
function Title({ name, type, isOpen }: TitleProps) {
return (
<div className="cursor-pointer text-nowrap rounded-[5px] p-1 nowrap flex items-center gap-2 aria-selected:bg-neutral-600 aria-selected:text-white hover:text-white">
<div className="flex-shrink-0">
{type === "folder" && <FolderIcon isOpen={isOpen} />}
{type === "file" && <FileIcon filename={name} />}
</div>
<div className="flex-grow">{name}</div>
</div>
);
}
import { Filename } from "./filename";
interface TreeNodeProps {
path: string;
@ -83,7 +65,7 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
onClick={handleClick}
className="flex items-center justify-between w-full px-1"
>
<Title
<Filename
name={filename}
type={isDirectory ? "folder" : "file"}
isOpen={isOpen}

View File

@ -1,11 +1,11 @@
import React from "react";
import { isGitHubErrorReponse } from "#/api/github";
import { SuggestionBox } from "#/routes/_oh._index/suggestion-box";
import { ConnectToGitHubModal } from "./modals/connect-to-github-modal";
import { ModalBackdrop } from "./modals/modal-backdrop";
import { GitHubRepositorySelector } from "#/routes/_oh._index/github-repo-selector";
import ModalButton from "./buttons/modal-button";
import { SuggestionBox } from "#/components/features/suggestions/suggestion-box";
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import { GitHubRepositorySelector } from "./github-repo-selector";
import { ModalButton } from "#/components/shared/buttons/modal-button";
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
interface GitHubRepositoriesSuggestionBoxProps {
handleSubmit: () => void;

View File

@ -0,0 +1,21 @@
import { RemoveButton } from "#/components/shared/buttons/remove-button";
import { Thumbnail } from "./thumbnail";
interface ImagePreviewProps {
src: string;
onRemove?: () => void;
size?: "small" | "large";
}
export function ImagePreview({
src,
onRemove,
size = "small",
}: ImagePreviewProps) {
return (
<div data-testid="image-preview" className="relative w-fit shrink-0">
<Thumbnail src={src} size={size} />
{onRemove && <RemoveButton onClick={onRemove} />}
</div>
);
}

View File

@ -0,0 +1,21 @@
import { cn } from "#/utils/utils";
interface ThumbnailProps {
src: string;
size?: "small" | "large";
}
export function Thumbnail({ src, size = "small" }: ThumbnailProps) {
return (
<img
role="img"
alt=""
src={src}
className={cn(
"rounded object-cover",
size === "small" && "w-[62px] h-[62px]",
size === "large" && "w-[100px] h-[100px]",
)}
/>
);
}

View File

@ -0,0 +1,22 @@
import SyntaxHighlighter from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
interface JupytrerCellInputProps {
code: string;
}
export function JupytrerCellInput({ code }: JupytrerCellInputProps) {
return (
<div className="rounded-lg bg-gray-800 dark:bg-gray-900 p-2 text-xs">
<div className="mb-1 text-gray-400">EXECUTE</div>
<pre
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5"
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
>
<SyntaxHighlighter language="python" style={atomOneDark} wrapLongLines>
{code}
</SyntaxHighlighter>
</pre>
</div>
);
}

View File

@ -0,0 +1,40 @@
import Markdown from "react-markdown";
import SyntaxHighlighter from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { JupyterLine } from "#/utils/parse-cell-content";
interface JupyterCellOutputProps {
lines: JupyterLine[];
}
export function JupyterCellOutput({ lines }: JupyterCellOutputProps) {
return (
<div className="rounded-lg bg-gray-800 dark:bg-gray-900 p-2 text-xs">
<div className="mb-1 text-gray-400">STDOUT/STDERR</div>
<pre
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5 max-h-[60vh] bg-gray-800"
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
>
{/* display the lines as plaintext or image */}
{lines.map((line, index) => {
if (line.type === "image") {
return (
<div key={index}>
<Markdown urlTransform={(value: string) => value}>
{line.content}
</Markdown>
</div>
);
}
return (
<div key={index}>
<SyntaxHighlighter language="plaintext" style={atomOneDark}>
{line.content}
</SyntaxHighlighter>
</div>
);
})}
</pre>
</div>
);
}

View File

@ -0,0 +1,23 @@
import React from "react";
import { Cell } from "#/state/jupyter-slice";
import { JupyterLine, parseCellContent } from "#/utils/parse-cell-content";
import { JupytrerCellInput } from "./jupyter-cell-input";
import { JupyterCellOutput } from "./jupyter-cell-output";
interface JupyterCellProps {
cell: Cell;
}
export function JupyterCell({ cell }: JupyterCellProps) {
const [lines, setLines] = React.useState<JupyterLine[]>([]);
React.useEffect(() => {
setLines(parseCellContent(cell.content));
}, [cell.content]);
if (cell.type === "input") {
return <JupytrerCellInput code={cell.content} />;
}
return <JupyterCellOutput lines={lines} />;
}

View File

@ -0,0 +1,37 @@
import React from "react";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import { JupyterCell } from "./jupyter-cell";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
interface JupyterEditorProps {
maxWidth: number;
}
export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
const { cells } = useSelector((state: RootState) => state.jupyter);
const jupyterRef = React.useRef<HTMLDivElement>(null);
const { hitBottom, scrollDomToBottom, onChatBodyScroll } =
useScrollToBottom(jupyterRef);
return (
<div className="flex-1" style={{ maxWidth }}>
<div
className="overflow-y-auto h-full"
ref={jupyterRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
>
{cells.map((cell, index) => (
<JupyterCell key={index} cell={cell} />
))}
</div>
{!hitBottom && (
<div className="sticky bottom-2 flex items-center justify-center">
<ScrollToBottomButton onClick={scrollDomToBottom} />
</div>
)}
</div>
);
}

View File

@ -3,16 +3,16 @@ import { useDispatch } from "react-redux";
import toast from "react-hot-toast";
import posthog from "posthog-js";
import EllipsisH from "#/icons/ellipsis-h.svg?react";
import { ModalBackdrop } from "../modals/modal-backdrop";
import { ConnectToGitHubModal } from "../modals/connect-to-github-modal";
import { addUserMessage } from "#/state/chat-slice";
import { createChatMessage } from "#/services/chat-service";
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
import { ProjectMenuDetails } from "./project-menu-details";
import { downloadWorkspace } from "#/utils/download-workspace";
import { LoadingSpinner } from "../modals/loading-project";
import { useWsClient } from "#/context/ws-client-provider";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
interface ProjectMenuCardProps {
isConnectedToGitHub: boolean;

View File

@ -0,0 +1,9 @@
interface AvatarProps {
src: string;
}
export function Avatar({ src }: AvatarProps) {
return (
<img src={src} alt="user avatar" className="w-full h-full rounded-full" />
);
}

View File

@ -1,18 +1,18 @@
import React from "react";
import { useLocation } from "react-router-dom";
import { LoadingSpinner } from "#/components/modals/loading-project";
import { UserActions } from "#/components/user-actions";
import { useAuth } from "#/context/auth-context";
import { useUserPrefs } from "#/context/user-prefs-context";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { SettingsModal } from "./modals/settings-modal";
import { ExitProjectConfirmationModal } from "./modals/exit-project-confirmation-modal";
import { AllHandsLogoButton } from "./buttons/all-hands-logo-button";
import { SettingsButton } from "./buttons/settings-button";
import { DocsButton } from "./buttons/docs-button";
import { ExitProjectButton } from "./buttons/exit-project-button";
import { AccountSettingsModal } from "./modals/account-settings-modal";
import { UserActions } from "./user-actions";
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
import { DocsButton } from "#/components/shared/buttons/docs-button";
import { ExitProjectButton } from "#/components/shared/buttons/exit-project-button";
import { SettingsButton } from "#/components/shared/buttons/settings-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
import { ExitProjectConfirmationModal } from "#/components/shared/modals/exit-project-confirmation-modal";
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
export function Sidebar() {
const location = useLocation();

View File

@ -1,6 +1,6 @@
import React from "react";
import { AccountSettingsContextMenu } from "./context-menu/account-settings-context-menu";
import { UserAvatar } from "./user-avatar";
import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu";
interface UserActionsProps {
onClickAccountSettings: () => void;

View File

@ -1,6 +1,7 @@
import { LoadingSpinner } from "./modals/loading-project";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import DefaultUserAvatar from "#/icons/default-user.svg?react";
import { cn } from "#/utils/utils";
import { Avatar } from "./avatar";
interface UserAvatarProps {
onClick: () => void;
@ -19,13 +20,7 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
isLoading && "bg-transparent",
)}
>
{!isLoading && avatarUrl && (
<img
src={avatarUrl}
alt="user avatar"
className="w-full h-full rounded-full"
/>
)}
{!isLoading && avatarUrl && <Avatar src={avatarUrl} />}
{!isLoading && !avatarUrl && (
<DefaultUserAvatar
aria-label="user avatar placeholder"

View File

@ -1,5 +1,5 @@
import { RefreshButton } from "#/components/shared/buttons/refresh-button";
import Lightbulb from "#/icons/lightbulb.svg?react";
import Refresh from "#/icons/refresh.svg?react";
interface SuggestionBubbleProps {
suggestion: string;
@ -12,6 +12,11 @@ export function SuggestionBubble({
onClick,
onRefresh,
}: SuggestionBubbleProps) {
const handleRefresh = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onRefresh();
};
return (
<div
onClick={onClick}
@ -21,15 +26,7 @@ export function SuggestionBubble({
<Lightbulb width={18} height={18} />
<span className="text-sm">{suggestion}</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRefresh();
}}
>
<Refresh width={14} height={14} />
</button>
<RefreshButton onClick={handleRefresh} />
</div>
);
}

View File

@ -1,6 +1,6 @@
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { useTerminal } from "../../hooks/use-terminal";
import { useTerminal } from "#/hooks/use-terminal";
import "@xterm/xterm/css/xterm.css";

View File

@ -0,0 +1,12 @@
export function JoinWaitlistAnchor() {
return (
<a
href="https://www.all-hands.dev/join-waitlist"
target="_blank"
rel="noreferrer"
className="rounded bg-[#FFE165] text-black text-sm font-bold py-[10px] w-full text-center hover:opacity-80"
>
Join Waitlist
</a>
);
}

View File

@ -0,0 +1,35 @@
interface WaitlistMessageProps {
content: "waitlist" | "sign-in";
}
export function WaitlistMessage({ content }: WaitlistMessageProps) {
return (
<div className="flex flex-col gap-2 w-full items-center text-center">
<h1 className="text-2xl font-bold">
{content === "sign-in" && "Sign in with GitHub"}
{content === "waitlist" && "Just a little longer!"}
</h1>
{content === "sign-in" && (
<p>
or{" "}
<a
href="https://www.all-hands.dev/join-waitlist"
target="_blank"
rel="noreferrer noopener"
className="text-blue-500 hover:underline underline-offset-2"
>
join the waitlist
</a>{" "}
if you haven&apos;t already
</p>
)}
{content === "waitlist" && (
<p className="text-sm">
Thanks for your patience! We&apos;re accepting new members
progressively. If you haven&apos;t joined the waitlist yet, now&apos;s
the time!
</p>
)}
</div>
);
}

View File

@ -0,0 +1,37 @@
import { ModalBody } from "@nextui-org/react";
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import { JoinWaitlistAnchor } from "./join-waitlist-anchor";
import { WaitlistMessage } from "./waitlist-message";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalButton } from "#/components/shared/buttons/modal-button";
interface WaitlistModalProps {
ghToken: string | null;
githubAuthUrl: string | null;
}
export function WaitlistModal({ ghToken, githubAuthUrl }: WaitlistModalProps) {
return (
<ModalBackdrop>
<ModalBody>
<AllHandsLogo width={68} height={46} />
<WaitlistMessage content={ghToken ? "waitlist" : "sign-in"} />
{!ghToken && (
<ModalButton
text="Connect to GitHub"
icon={<GitHubLogo width={20} height={20} />}
className="bg-[#791B80] w-full"
onClick={() => {
if (githubAuthUrl) {
window.location.href = githubAuthUrl;
}
}}
/>
)}
{ghToken && <JoinWaitlistAnchor />}
</ModalBody>
</ModalBackdrop>
);
}

View File

@ -1,33 +0,0 @@
import { DiJavascript } from "react-icons/di";
import {
FaCss3,
FaFile,
FaHtml5,
FaList,
FaMarkdown,
FaNpm,
FaPython,
} from "react-icons/fa";
import { getExtension } from "#/utils/utils";
const EXTENSION_ICON_MAP: Record<string, JSX.Element> = {
js: <DiJavascript />,
ts: <DiJavascript />,
py: <FaPython />,
css: <FaCss3 />,
json: <FaList />,
npmignore: <FaNpm />,
html: <FaHtml5 />,
md: <FaMarkdown />,
};
interface FileIconProps {
filename: string;
}
function FileIcon({ filename }: FileIconProps) {
const extension = getExtension(filename);
return EXTENSION_ICON_MAP[extension] || <FaFile />;
}
export default FileIcon;

View File

@ -1,41 +0,0 @@
import CloseIcon from "#/icons/close.svg?react";
import { cn } from "#/utils/utils";
interface ImagePreviewProps {
src: string;
onRemove?: () => void;
size?: "small" | "large";
}
export function ImagePreview({
src,
onRemove,
size = "small",
}: ImagePreviewProps) {
return (
<div data-testid="image-preview" className="relative w-fit shrink-0">
<img
role="img"
src={src}
alt=""
className={cn(
"rounded object-cover",
size === "small" && "w-[62px] h-[62px]",
size === "large" && "w-[100px] h-[100px]",
)}
/>
{onRemove && (
<button
type="button"
onClick={onRemove}
className={cn(
"bg-neutral-400 rounded-full w-3 h-3 flex items-center justify-center",
"absolute right-[3px] top-[3px]",
)}
>
<CloseIcon width={10} height={10} />
</button>
)}
</div>
);
}

View File

@ -1,128 +0,0 @@
import React from "react";
import { useSelector } from "react-redux";
import SyntaxHighlighter from "react-syntax-highlighter";
import Markdown from "react-markdown";
import { VscArrowDown } from "react-icons/vsc";
import { useTranslation } from "react-i18next";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { RootState } from "#/store";
import { Cell } from "#/state/jupyter-slice";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import { I18nKey } from "#/i18n/declaration";
interface IJupyterCell {
cell: Cell;
}
function JupyterCell({ cell }: IJupyterCell): JSX.Element {
const code = cell.content;
if (cell.type === "input") {
return (
<div className="rounded-lg bg-gray-800 dark:bg-gray-900 p-2 text-xs">
<div className="mb-1 text-gray-400">EXECUTE</div>
<pre
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5"
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
>
<SyntaxHighlighter
language="python"
style={atomOneDark}
wrapLongLines
>
{code}
</SyntaxHighlighter>
</pre>
</div>
);
}
// aggregate all the NON-image lines into a single plaintext.
const lines: { type: "plaintext" | "image"; content: string }[] = [];
let current = "";
for (const line of code.split("\n")) {
if (line.startsWith("![image](data:image/png;base64,")) {
lines.push({ type: "plaintext", content: current });
lines.push({ type: "image", content: line });
current = "";
} else {
current += `${line}\n`;
}
}
lines.push({ type: "plaintext", content: current });
return (
<div className="rounded-lg bg-gray-800 dark:bg-gray-900 p-2 text-xs">
<div className="mb-1 text-gray-400">STDOUT/STDERR</div>
<pre
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5 max-h-[60vh] bg-gray-800"
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
>
{/* display the lines as plaintext or image */}
{lines.map((line, index) => {
if (line.type === "image") {
return (
<div key={index}>
<Markdown urlTransform={(value: string) => value}>
{line.content}
</Markdown>
</div>
);
}
return (
<div key={index}>
<SyntaxHighlighter language="plaintext" style={atomOneDark}>
{line.content}
</SyntaxHighlighter>
</div>
);
})}
</pre>
</div>
);
}
interface JupyterEditorProps {
maxWidth: number;
}
function JupyterEditor({ maxWidth }: JupyterEditorProps) {
const { t } = useTranslation();
const { cells } = useSelector((state: RootState) => state.jupyter);
const jupyterRef = React.useRef<HTMLDivElement>(null);
const { hitBottom, scrollDomToBottom, onChatBodyScroll } =
useScrollToBottom(jupyterRef);
return (
<div className="flex-1" style={{ maxWidth }}>
<div
className="overflow-y-auto h-full"
ref={jupyterRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
>
{cells.map((cell, index) => (
<JupyterCell key={index} cell={cell} />
))}
</div>
{!hitBottom && (
<div className="sticky bottom-2 flex items-center justify-center">
<button
type="button"
className="relative border-1 text-sm rounded px-3 py-1 border-neutral-600 bg-neutral-700 cursor-pointer select-none"
>
<span className="flex items-center" onClick={scrollDomToBottom}>
<VscArrowDown className="inline mr-2 w-3 h-3" />
<span className="inline-block" onClick={scrollDomToBottom}>
{t(I18nKey.CHAT_INTERFACE$TO_BOTTOM)}
</span>
</span>
</button>
</div>
)}
</div>
);
}
export default JupyterEditor;

View File

@ -0,0 +1,7 @@
export function BetaBadge() {
return (
<span className="text-[11px] leading-5 text-root-primary bg-neutral-400 px-1 rounded-xl">
Beta
</span>
);
}

View File

@ -1,14 +1,6 @@
import { NavLink } from "@remix-run/react";
import clsx from "clsx";
import React from "react";
function BetaBadge() {
return (
<span className="text-[11px] leading-5 text-root-primary bg-neutral-400 px-1 rounded-xl">
Beta
</span>
);
}
import { NavTab } from "./nav-tab";
interface ContainerProps {
label?: string;
@ -38,23 +30,7 @@ export function Container({
{labels && (
<div className="flex text-xs h-[36px]">
{labels.map(({ label: l, to, icon, isBeta }) => (
<NavLink
end
key={to}
to={to}
className={({ isActive }) =>
clsx(
"px-2 border-b border-r border-neutral-600 bg-root-primary flex-1",
"first-of-type:rounded-tl-xl last-of-type:rounded-tr-xl last-of-type:border-r-0",
"flex items-center gap-2",
isActive && "bg-root-secondary",
)
}
>
{icon}
{l}
{isBeta && <BetaBadge />}
</NavLink>
<NavTab key={to} to={to} label={l} icon={icon} isBeta={isBeta} />
))}
</div>
)}

View File

@ -0,0 +1,32 @@
import { NavLink } from "react-router-dom";
import { cn } from "#/utils/utils";
import { BetaBadge } from "./beta-badge";
interface NavTabProps {
to: string;
label: string;
icon: React.ReactNode;
isBeta?: boolean;
}
export function NavTab({ to, label, icon, isBeta }: NavTabProps) {
return (
<NavLink
end
key={to}
to={to}
className={({ isActive }) =>
cn(
"px-2 border-b border-r border-neutral-600 bg-root-primary flex-1",
"first-of-type:rounded-tl-xl last-of-type:rounded-tr-xl last-of-type:border-r-0",
"flex items-center gap-2",
isActive && "bg-root-secondary",
)
}
>
{icon}
{label}
{isBeta && <BetaBadge />}
</NavLink>
);
}

View File

@ -1,26 +0,0 @@
interface ScrollButtonProps {
onClick: () => void;
icon: JSX.Element;
label: string;
disabled?: boolean;
}
export function ScrollButton({
onClick,
icon,
label,
disabled = false,
}: ScrollButtonProps): JSX.Element {
return (
<button
type="button"
className="relative border-1 text-xs rounded px-2 py-1 border-neutral-600 bg-neutral-700 cursor-pointer select-none"
onClick={onClick}
disabled={disabled}
>
<div className="flex items-center">
{icon} <span className="inline-block">{label}</span>
</div>
</button>
);
}

View File

@ -0,0 +1,33 @@
import { Tooltip } from "@nextui-org/react";
import { useTranslation } from "react-i18next";
import ConfirmIcon from "#/assets/confirm";
import RejectIcon from "#/assets/reject";
import { I18nKey } from "#/i18n/declaration";
interface ActionTooltipProps {
type: "confirm" | "reject";
onClick: () => void;
}
export function ActionTooltip({ type, onClick }: ActionTooltipProps) {
const { t } = useTranslation();
const content =
type === "confirm"
? t(I18nKey.CHAT_INTERFACE$USER_CONFIRMED)
: t(I18nKey.CHAT_INTERFACE$USER_REJECTED);
return (
<Tooltip content={content} closeDelay={100}>
<button
data-testid={`action-${type}-button`}
type="button"
aria-label={type === "confirm" ? "Confirm action" : "Reject action"}
className="bg-neutral-700 rounded-full p-1 hover:bg-neutral-800"
onClick={onClick}
>
{type === "confirm" ? <ConfirmIcon /> : <RejectIcon />}
</button>
</Tooltip>
);
}

Some files were not shown because too many files have changed in this diff Show More