mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
refactor(frontend) Refactor and move components (#5290)
This commit is contained in:
parent
3e49f0f827
commit
b9b6cfd406
@ -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
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)[]) =>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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"];
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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" };
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
64
frontend/src/components/agent-status-map.constant.ts
Normal file
64
frontend/src/components/agent-status-map.constant.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
20
frontend/src/components/extension-icon-map.constant.tsx
Normal file
20
frontend/src/components/extension-icon-map.constant.tsx
Normal 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 />,
|
||||
};
|
||||
@ -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 {
|
||||
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
@ -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
|
||||
@ -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={{
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
@ -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,
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
17
frontend/src/components/features/controls/security-lock.tsx
Normal file
17
frontend/src/components/features/controls/security-lock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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";
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
12
frontend/src/components/features/file-explorer/file-icon.tsx
Normal file
12
frontend/src/components/features/file-explorer/file-icon.tsx
Normal 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 />;
|
||||
}
|
||||
20
frontend/src/components/features/file-explorer/filename.tsx
Normal file
20
frontend/src/components/features/file-explorer/filename.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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}
|
||||
@ -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;
|
||||
21
frontend/src/components/features/images/image-preview.tsx
Normal file
21
frontend/src/components/features/images/image-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/features/images/thumbnail.tsx
Normal file
21
frontend/src/components/features/images/thumbnail.tsx
Normal 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]",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/features/jupyter/jupyter-cell.tsx
Normal file
23
frontend/src/components/features/jupyter/jupyter-cell.tsx
Normal 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} />;
|
||||
}
|
||||
37
frontend/src/components/features/jupyter/jupyter.tsx
Normal file
37
frontend/src/components/features/jupyter/jupyter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
9
frontend/src/components/features/sidebar/avatar.tsx
Normal file
9
frontend/src/components/features/sidebar/avatar.tsx
Normal 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" />
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
@ -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;
|
||||
@ -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"
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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't already
|
||||
</p>
|
||||
)}
|
||||
{content === "waitlist" && (
|
||||
<p className="text-sm">
|
||||
Thanks for your patience! We're accepting new members
|
||||
progressively. If you haven't joined the waitlist yet, now's
|
||||
the time!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/features/waitlist/waitlist-modal.tsx
Normal file
37
frontend/src/components/features/waitlist/waitlist-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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(") {
|
||||
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;
|
||||
7
frontend/src/components/layout/beta-badge.tsx
Normal file
7
frontend/src/components/layout/beta-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
32
frontend/src/components/layout/nav-tab.tsx
Normal file
32
frontend/src/components/layout/nav-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/shared/action-tooltip.tsx
Normal file
33
frontend/src/components/shared/action-tooltip.tsx
Normal 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
Loading…
x
Reference in New Issue
Block a user