mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 23:08:04 -05:00
refactor(frontend) Refactor and move components (#5290)
This commit is contained in:
@@ -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)
|
- Remix SPA Mode (React + Vite + React Router)
|
||||||
- TypeScript
|
- TypeScript
|
||||||
- Redux
|
- Redux
|
||||||
|
- TanStack Query
|
||||||
- Tailwind CSS
|
- Tailwind CSS
|
||||||
- i18next
|
- i18next
|
||||||
- React Testing Library
|
- React Testing Library
|
||||||
@@ -85,7 +86,7 @@ frontend
|
|||||||
├── src
|
├── src
|
||||||
│ ├── api # API calls
|
│ ├── api # API calls
|
||||||
│ ├── assets
|
│ ├── assets
|
||||||
│ ├── components # Reusable components
|
│ ├── components
|
||||||
│ ├── context # Local state management
|
│ ├── context # Local state management
|
||||||
│ ├── hooks # Custom hooks
|
│ ├── hooks # Custom hooks
|
||||||
│ ├── i18n # Internationalization
|
│ ├── i18n # Internationalization
|
||||||
@@ -99,6 +100,18 @@ frontend
|
|||||||
└── .env.sample # Sample environment variables
|
└── .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
|
### Features
|
||||||
|
|
||||||
- Real-time updates with WebSockets
|
- Real-time updates with WebSockets
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { screen } from "@testing-library/react";
|
import { screen } from "@testing-library/react";
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { renderWithProviders } from "../../test-utils";
|
import { renderWithProviders } from "../../test-utils";
|
||||||
import BrowserPanel from "#/components/browser";
|
import { BrowserPanel } from "#/components/features/browser/browser";
|
||||||
|
|
||||||
describe("Browser", () => {
|
describe("Browser", () => {
|
||||||
it("renders a message if no screenshotSrc is provided", () => {
|
it("renders a message if no screenshotSrc is provided", () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, it, expect, test } from "vitest";
|
import { describe, it, expect, test } from "vitest";
|
||||||
import { ChatMessage } from "#/components/chat-message";
|
import { ChatMessage } from "#/components/features/chat/chat-message";
|
||||||
|
|
||||||
describe("ChatMessage", () => {
|
describe("ChatMessage", () => {
|
||||||
it("should render a user message", () => {
|
it("should render a user message", () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { describe, afterEach, vi, it, expect } from "vitest";
|
import { describe, afterEach, vi, it, expect } from "vitest";
|
||||||
import { ChatInput } from "#/components/chat-input";
|
import { ChatInput } from "#/components/features/chat/chat-input";
|
||||||
|
|
||||||
describe("ChatInput", () => {
|
describe("ChatInput", () => {
|
||||||
const onSubmitMock = vi.fn();
|
const onSubmitMock = vi.fn();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { addUserMessage } from "#/state/chat-slice";
|
|||||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||||
import * as ChatSlice from "#/state/chat-slice";
|
import * as ChatSlice from "#/state/chat-slice";
|
||||||
import { WsClientProviderStatus } from "#/context/ws-client-provider";
|
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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const renderChatInterface = (messages: (Message | ErrorMessage)[]) =>
|
const renderChatInterface = (messages: (Message | ErrorMessage)[]) =>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, test, vi } from "vitest";
|
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", () => {
|
describe("AccountSettingsContextMenu", () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
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", () => {
|
describe("ContextMenuListItem", () => {
|
||||||
it("should render the component with the children", () => {
|
it("should render the component with the children", () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { FeedbackActions } from "#/components/feedback-actions";
|
import { FeedbackActions } from "#/components/features/feedback/feedback-actions";
|
||||||
|
|
||||||
describe("FeedbackActions", () => {
|
describe("FeedbackActions", () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { screen } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { renderWithProviders } from "test-utils";
|
import { renderWithProviders } from "test-utils";
|
||||||
import { FeedbackForm } from "#/components/feedback-form";
|
import { FeedbackForm } from "#/components/features/feedback/feedback-form";
|
||||||
|
|
||||||
describe("FeedbackForm", () => {
|
describe("FeedbackForm", () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { screen } from "@testing-library/react";
|
import { screen } from "@testing-library/react";
|
||||||
import { renderWithProviders } from "test-utils";
|
import { renderWithProviders } from "test-utils";
|
||||||
import { describe, afterEach, vi, it, expect } from "vitest";
|
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"];
|
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 { describe, it, expect, vi, Mock, afterEach } from "vitest";
|
||||||
import toast from "#/utils/toast";
|
import toast from "#/utils/toast";
|
||||||
import AgentState from "#/types/agent-state";
|
import AgentState from "#/types/agent-state";
|
||||||
import { FileExplorer } from "#/routes/_oh.app._index/file-explorer/file-explorer";
|
|
||||||
import OpenHands from "#/api/open-hands";
|
import OpenHands from "#/api/open-hands";
|
||||||
|
import { FileExplorer } from "#/components/features/file-explorer/file-explorer";
|
||||||
|
|
||||||
const toastSpy = vi.spyOn(toast, "error");
|
const toastSpy = vi.spyOn(toast, "error");
|
||||||
const uploadFilesSpy = vi.spyOn(OpenHands, "uploadFiles");
|
const uploadFilesSpy = vi.spyOn(OpenHands, "uploadFiles");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { screen } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { renderWithProviders } from "test-utils";
|
import { renderWithProviders } from "test-utils";
|
||||||
import { vi, describe, afterEach, it, expect } from "vitest";
|
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";
|
import OpenHands from "#/api/open-hands";
|
||||||
|
|
||||||
const getFileSpy = vi.spyOn(OpenHands, "getFile");
|
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 { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { ImagePreview } from "#/components/image-preview";
|
|
||||||
|
|
||||||
describe("ImagePreview", () => {
|
describe("ImagePreview", () => {
|
||||||
it("should render an image", () => {
|
it("should render an image", () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
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", () => {
|
describe("InteractiveChatBox", () => {
|
||||||
const onSubmitMock = vi.fn();
|
const onSubmitMock = vi.fn();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen, act } from "@testing-library/react";
|
import { render, screen, act } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, it, vi, expect } from "vitest";
|
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", () => {
|
describe("BaseModal", () => {
|
||||||
it("should render if the modal is open", () => {
|
it("should render if the modal is open", () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
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", () => {
|
describe("ModelSelector", () => {
|
||||||
const models = {
|
const models = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { SuggestionItem } from "#/components/suggestion-item";
|
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
|
||||||
|
|
||||||
describe("SuggestionItem", () => {
|
describe("SuggestionItem", () => {
|
||||||
const suggestionItem = { label: "suggestion1", value: "a long text value" };
|
const suggestionItem = { label: "suggestion1", value: "a long text value" };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { Suggestions } from "#/components/suggestions";
|
import { Suggestions } from "#/components/features/suggestions/suggestions";
|
||||||
|
|
||||||
describe("Suggestions", () => {
|
describe("Suggestions", () => {
|
||||||
const firstSuggestion = {
|
const firstSuggestion = {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { act, screen } from "@testing-library/react";
|
|||||||
import { renderWithProviders } from "test-utils";
|
import { renderWithProviders } from "test-utils";
|
||||||
import { vi, describe, afterEach, it, expect } from "vitest";
|
import { vi, describe, afterEach, it, expect } from "vitest";
|
||||||
import { Command, appendInput, appendOutput } from "#/state/command-slice";
|
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(() => ({
|
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||||
observe: vi.fn(),
|
observe: vi.fn(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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", () => {
|
describe("UploadImageInput", () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it, test, vi, afterEach } from "vitest";
|
import { describe, expect, it, test, vi, afterEach } from "vitest";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { UserActions } from "#/components/user-actions";
|
import { UserActions } from "#/components/features/sidebar/user-actions";
|
||||||
|
|
||||||
describe("UserActions", () => {
|
describe("UserActions", () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { UserAvatar } from "#/components/user-avatar";
|
import { UserAvatar } from "#/components/features/sidebar/user-avatar";
|
||||||
|
|
||||||
describe("UserAvatar", () => {
|
describe("UserAvatar", () => {
|
||||||
const onClickMock = vi.fn();
|
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 { ModalButton } from "#/components/shared/buttons/modal-button";
|
||||||
import ModalBody from "./modals/modal-body";
|
|
||||||
import ModalButton from "./buttons/modal-button";
|
|
||||||
import {
|
import {
|
||||||
BaseModalTitle,
|
BaseModalTitle,
|
||||||
BaseModalDescription,
|
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";
|
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||||
|
|
||||||
interface AnalyticsConsentFormModalProps {
|
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 { useSelector } from "react-redux";
|
||||||
import { I18nKey } from "#/i18n/declaration";
|
|
||||||
import { RootState } from "#/store";
|
import { RootState } from "#/store";
|
||||||
|
import { BrowserSnapshot } from "./browser-snapshot";
|
||||||
|
import { EmptyBrowserMessage } from "./empty-browser-message";
|
||||||
|
|
||||||
function BrowserPanel() {
|
export function BrowserPanel() {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const { url, screenshotSrc } = useSelector(
|
const { url, screenshotSrc } = useSelector(
|
||||||
(state: RootState) => state.browser,
|
(state: RootState) => state.browser,
|
||||||
);
|
);
|
||||||
@@ -23,21 +20,11 @@ function BrowserPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="overflow-y-auto grow scrollbar-hide rounded-xl">
|
<div className="overflow-y-auto grow scrollbar-hide rounded-xl">
|
||||||
{screenshotSrc ? (
|
{screenshotSrc ? (
|
||||||
<img
|
<BrowserSnapshot src={imgSrc} />
|
||||||
src={imgSrc}
|
|
||||||
style={{ objectFit: "contain", width: "100%", height: "auto" }}
|
|
||||||
className="rounded-xl"
|
|
||||||
alt="Browser Screenshot"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center h-full justify-center">
|
<EmptyBrowserMessage />
|
||||||
<IoIosGlobe size={100} />
|
|
||||||
{t(I18nKey.BROWSER$EMPTY_MESSAGE)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 posthog from "posthog-js";
|
||||||
import React from "react";
|
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 { useAuth } from "#/context/auth-context";
|
||||||
import { downloadWorkspace } from "#/utils/download-workspace";
|
import { downloadWorkspace } from "#/utils/download-workspace";
|
||||||
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import TextareaAutosize from "react-textarea-autosize";
|
import TextareaAutosize from "react-textarea-autosize";
|
||||||
import ArrowSendIcon from "#/icons/arrow-send.svg?react";
|
|
||||||
import { cn } from "#/utils/utils";
|
import { cn } from "#/utils/utils";
|
||||||
|
import { SubmitButton } from "#/components/shared/buttons/submit-button";
|
||||||
|
import { StopButton } from "#/components/shared/buttons/stop-button";
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -132,27 +133,10 @@ export function ChatInput({
|
|||||||
{showButton && (
|
{showButton && (
|
||||||
<div className={buttonClassName}>
|
<div className={buttonClassName}>
|
||||||
{button === "submit" && (
|
{button === "submit" && (
|
||||||
<button
|
<SubmitButton isDisabled={disabled} onClick={handleSubmitMessage} />
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
{button === "stop" && (
|
{button === "stop" && (
|
||||||
<button
|
<StopButton isDisabled={disabled} onClick={onStop} />
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2,23 +2,23 @@ import { useDispatch, useSelector } from "react-redux";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
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 { 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 { addUserMessage } from "#/state/chat-slice";
|
||||||
import { RootState } from "#/store";
|
import { RootState } from "#/store";
|
||||||
import AgentState from "#/types/agent-state";
|
import AgentState from "#/types/agent-state";
|
||||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
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 { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||||
import TypingIndicator from "../../components/chat/typing-indicator";
|
import { TypingIndicator } from "./typing-indicator";
|
||||||
import { ContinueButton } from "../../components/continue-button";
|
|
||||||
import { ScrollToBottomButton } from "../../components/scroll-to-bottom-button";
|
|
||||||
import { useWsClient } from "#/context/ws-client-provider";
|
import { useWsClient } from "#/context/ws-client-provider";
|
||||||
import { Messages } from "./messages";
|
import { Messages } from "./messages";
|
||||||
import { LoadingSpinner } from "./loading-spinner";
|
|
||||||
import { ChatSuggestions } from "./chat-suggestions";
|
import { ChatSuggestions } from "./chat-suggestions";
|
||||||
import { ActionSuggestions } from "./action-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() {
|
export function ChatInterface() {
|
||||||
const { send, isLoadingMessages } = useWsClient();
|
const { send, isLoadingMessages } = useWsClient();
|
||||||
@@ -81,7 +81,11 @@ export function ChatInterface() {
|
|||||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||||
className="flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2"
|
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 && (
|
{!isLoadingMessages && (
|
||||||
<Messages
|
<Messages
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import CheckmarkIcon from "#/icons/checkmark.svg?react";
|
import { code } from "../markdown/code";
|
||||||
import CopyIcon from "#/icons/copy.svg?react";
|
|
||||||
import { code } from "./markdown/code";
|
|
||||||
import { cn } from "#/utils/utils";
|
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 {
|
interface ChatMessageProps {
|
||||||
type: "user" | "assistant";
|
type: "user" | "assistant";
|
||||||
@@ -51,23 +50,12 @@ export function ChatMessage({
|
|||||||
type === "assistant" && "pb-4 max-w-full bg-tranparent",
|
type === "assistant" && "pb-4 max-w-full bg-tranparent",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<CopyToClipboardButton
|
||||||
hidden={!isHovering}
|
isHidden={!isHovering}
|
||||||
disabled={isCopy}
|
isDisabled={isCopy}
|
||||||
data-testid="copy-to-clipboard"
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopyToClipboard}
|
onClick={handleCopyToClipboard}
|
||||||
className={cn(
|
mode={isCopy ? "copied" : "copy"}
|
||||||
"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>
|
|
||||||
<Markdown
|
<Markdown
|
||||||
className="text-sm overflow-auto"
|
className="text-sm overflow-auto"
|
||||||
components={{
|
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 BuildIt from "#/icons/build-it.svg?react";
|
||||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { UploadImageInput } from "./upload-image-input";
|
|
||||||
import { ChatInput } from "./chat-input";
|
import { ChatInput } from "./chat-input";
|
||||||
import { cn } from "#/utils/utils";
|
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 {
|
interface InteractiveChatBoxProps {
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ChatMessage } from "#/components/chat-message";
|
import { ChatMessage } from "#/components/features/chat/chat-message";
|
||||||
import ConfirmationButtons from "#/components/chat/confirmation-buttons";
|
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||||
import { ErrorMessage } from "#/components/error-message";
|
import { ImageCarousel } from "../images/image-carousel";
|
||||||
import { ImageCarousel } from "#/components/image-carousel";
|
import { ErrorMessage } from "./error-message";
|
||||||
|
|
||||||
const isErrorMessage = (
|
const isErrorMessage = (
|
||||||
message: Message | ErrorMessage,
|
message: Message | ErrorMessage,
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import React from "react";
|
export function TypingIndicator() {
|
||||||
|
|
||||||
function TypingIndicator(): React.ReactElement {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-1.5 bg-neutral-700 px-3 py-1.5 rounded-full">
|
<div className="flex items-center space-x-1.5 bg-neutral-700 px-3 py-1.5 rounded-full">
|
||||||
<span
|
<span
|
||||||
@@ -18,5 +16,3 @@ function TypingIndicator(): React.ReactElement {
|
|||||||
</div>
|
</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 React from "react";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import AgentControlBar from "./agent-control-bar";
|
import { AgentControlBar } from "./agent-control-bar";
|
||||||
import AgentStatusBar from "./agent-status-bar";
|
import { AgentStatusBar } from "./agent-status-bar";
|
||||||
import { ProjectMenuCard } from "./project-menu/ProjectMenuCard";
|
import { ProjectMenuCard } from "../project-menu/ProjectMenuCard";
|
||||||
import { useAuth } from "#/context/auth-context";
|
import { useAuth } from "#/context/auth-context";
|
||||||
import { RootState } from "#/store";
|
import { RootState } from "#/store";
|
||||||
|
import { SecurityLock } from "./security-lock";
|
||||||
|
|
||||||
interface ControlsProps {
|
interface ControlsProps {
|
||||||
setSecurityOpen: (isOpen: boolean) => void;
|
setSecurityOpen: (isOpen: boolean) => void;
|
||||||
@@ -42,13 +42,7 @@ export function Controls({
|
|||||||
<AgentStatusBar />
|
<AgentStatusBar />
|
||||||
|
|
||||||
{showSecurityLock && (
|
{showSecurityLock && (
|
||||||
<div
|
<SecurityLock onClick={() => setSecurityOpen(true)} />
|
||||||
className="cursor-pointer hover:opacity-80 transition-all"
|
|
||||||
style={{ marginRight: "8px" }}
|
|
||||||
onClick={() => setSecurityOpen(true)}
|
|
||||||
>
|
|
||||||
<IoLockClosed size={20} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { EditorActionButton } from "#/components/shared/buttons/editor-action-button";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditorActionsProps {
|
interface EditorActionsProps {
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
@@ -1,28 +1,6 @@
|
|||||||
import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
|
import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
|
||||||
import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
|
import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
|
||||||
|
import { FeedbackActionButton } from "#/components/shared/buttons/feedback-action-button";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FeedbackActionsProps {
|
interface FeedbackActionsProps {
|
||||||
onPositiveFeedback: () => void;
|
onPositiveFeedback: () => void;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import hotToast from "react-hot-toast";
|
import hotToast from "react-hot-toast";
|
||||||
import ModalButton from "./buttons/modal-button";
|
|
||||||
import { Feedback } from "#/api/open-hands.types";
|
import { Feedback } from "#/api/open-hands.types";
|
||||||
import { useSubmitFeedback } from "#/hooks/mutation/use-submit-feedback";
|
import { useSubmitFeedback } from "#/hooks/mutation/use-submit-feedback";
|
||||||
|
import { ModalButton } from "#/components/shared/buttons/modal-button";
|
||||||
|
|
||||||
const FEEDBACK_VERSION = "1.0";
|
const FEEDBACK_VERSION = "1.0";
|
||||||
const VIEWER_PAGE = "https://www.all-hands.dev/share";
|
const VIEWER_PAGE = "https://www.all-hands.dev/share";
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { FeedbackForm } from "./feedback-form";
|
|
||||||
import {
|
import {
|
||||||
BaseModalTitle,
|
BaseModalTitle,
|
||||||
BaseModalDescription,
|
BaseModalDescription,
|
||||||
} from "./modals/confirmation-modals/base-modal";
|
} from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||||
import { ModalBackdrop } from "./modals/modal-backdrop";
|
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||||
import ModalBody from "./modals/modal-body";
|
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||||
|
import { FeedbackForm } from "./feedback-form";
|
||||||
|
|
||||||
interface FeedbackModalProps {
|
interface FeedbackModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -7,7 +7,10 @@ interface ExplorerTreeProps {
|
|||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExplorerTree({ files, defaultOpen = false }: ExplorerTreeProps) {
|
export function ExplorerTree({
|
||||||
|
files,
|
||||||
|
defaultOpen = false,
|
||||||
|
}: ExplorerTreeProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (!files?.length) {
|
if (!files?.length) {
|
||||||
const message = !files
|
const message = !files
|
||||||
@@ -23,5 +26,3 @@ function ExplorerTree({ files, defaultOpen = false }: ExplorerTreeProps) {
|
|||||||
</div>
|
</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 { 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 {
|
interface ExplorerActionsProps {
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import AgentState from "#/types/agent-state";
|
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 toast from "#/utils/toast";
|
||||||
import { RootState } from "#/store";
|
import { RootState } from "#/store";
|
||||||
import { I18nKey } from "#/i18n/declaration";
|
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 { FileUploadSuccessResponse } from "#/api/open-hands.types";
|
||||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||||
import { cn } from "#/utils/utils";
|
import { cn } from "#/utils/utils";
|
||||||
import { OpenVSCodeButton } from "./buttons/open-vscode-button";
|
|
||||||
import { Dropzone } from "./dropzone";
|
import { Dropzone } from "./dropzone";
|
||||||
import { FileExplorerHeader } from "./file-explorer-header";
|
import { FileExplorerHeader } from "./file-explorer-header";
|
||||||
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
|
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
|
||||||
|
import { OpenVSCodeButton } from "#/components/shared/buttons/open-vscode-button";
|
||||||
|
|
||||||
interface FileExplorerProps {
|
interface FileExplorerProps {
|
||||||
isOpen: boolean;
|
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;
|
isOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FolderIcon({ isOpen }: FolderIconProps): JSX.Element {
|
export function FolderIcon({ isOpen }: FolderIconProps): JSX.Element {
|
||||||
return isOpen ? (
|
return isOpen ? (
|
||||||
<FaFolderOpen color="D9D3D0" className="icon" />
|
<FaFolderOpen color="D9D3D0" className="icon" />
|
||||||
) : (
|
) : (
|
||||||
<FaFolder color="D9D3D0" className="icon" />
|
<FaFolder color="D9D3D0" className="icon" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FolderIcon;
|
|
||||||
@@ -1,28 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import FolderIcon from "../folder-icon";
|
|
||||||
import FileIcon from "../file-icons";
|
|
||||||
import { useFiles } from "#/context/files";
|
import { useFiles } from "#/context/files";
|
||||||
import { cn } from "#/utils/utils";
|
import { cn } from "#/utils/utils";
|
||||||
import { useListFiles } from "#/hooks/query/use-list-files";
|
import { useListFiles } from "#/hooks/query/use-list-files";
|
||||||
import { useListFile } from "#/hooks/query/use-list-file";
|
import { useListFile } from "#/hooks/query/use-list-file";
|
||||||
|
import { Filename } from "./filename";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TreeNodeProps {
|
interface TreeNodeProps {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -83,7 +65,7 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className="flex items-center justify-between w-full px-1"
|
className="flex items-center justify-between w-full px-1"
|
||||||
>
|
>
|
||||||
<Title
|
<Filename
|
||||||
name={filename}
|
name={filename}
|
||||||
type={isDirectory ? "folder" : "file"}
|
type={isDirectory ? "folder" : "file"}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { isGitHubErrorReponse } from "#/api/github";
|
import { isGitHubErrorReponse } from "#/api/github";
|
||||||
import { SuggestionBox } from "#/routes/_oh._index/suggestion-box";
|
import { SuggestionBox } from "#/components/features/suggestions/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 GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
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 {
|
interface GitHubRepositoriesSuggestionBoxProps {
|
||||||
handleSubmit: () => void;
|
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 toast from "react-hot-toast";
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
import EllipsisH from "#/icons/ellipsis-h.svg?react";
|
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 { addUserMessage } from "#/state/chat-slice";
|
||||||
import { createChatMessage } from "#/services/chat-service";
|
import { createChatMessage } from "#/services/chat-service";
|
||||||
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
|
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
|
||||||
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
|
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
|
||||||
import { ProjectMenuDetails } from "./project-menu-details";
|
import { ProjectMenuDetails } from "./project-menu-details";
|
||||||
import { downloadWorkspace } from "#/utils/download-workspace";
|
import { downloadWorkspace } from "#/utils/download-workspace";
|
||||||
import { LoadingSpinner } from "../modals/loading-project";
|
|
||||||
import { useWsClient } from "#/context/ws-client-provider";
|
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 {
|
interface ProjectMenuCardProps {
|
||||||
isConnectedToGitHub: boolean;
|
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 React from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
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 { useAuth } from "#/context/auth-context";
|
||||||
import { useUserPrefs } from "#/context/user-prefs-context";
|
import { useUserPrefs } from "#/context/user-prefs-context";
|
||||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||||
import { SettingsModal } from "./modals/settings-modal";
|
import { UserActions } from "./user-actions";
|
||||||
import { ExitProjectConfirmationModal } from "./modals/exit-project-confirmation-modal";
|
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
|
||||||
import { AllHandsLogoButton } from "./buttons/all-hands-logo-button";
|
import { DocsButton } from "#/components/shared/buttons/docs-button";
|
||||||
import { SettingsButton } from "./buttons/settings-button";
|
import { ExitProjectButton } from "#/components/shared/buttons/exit-project-button";
|
||||||
import { DocsButton } from "./buttons/docs-button";
|
import { SettingsButton } from "#/components/shared/buttons/settings-button";
|
||||||
import { ExitProjectButton } from "./buttons/exit-project-button";
|
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||||
import { AccountSettingsModal } from "./modals/account-settings-modal";
|
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() {
|
export function Sidebar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { AccountSettingsContextMenu } from "./context-menu/account-settings-context-menu";
|
|
||||||
import { UserAvatar } from "./user-avatar";
|
import { UserAvatar } from "./user-avatar";
|
||||||
|
import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu";
|
||||||
|
|
||||||
interface UserActionsProps {
|
interface UserActionsProps {
|
||||||
onClickAccountSettings: () => void;
|
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 DefaultUserAvatar from "#/icons/default-user.svg?react";
|
||||||
import { cn } from "#/utils/utils";
|
import { cn } from "#/utils/utils";
|
||||||
|
import { Avatar } from "./avatar";
|
||||||
|
|
||||||
interface UserAvatarProps {
|
interface UserAvatarProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -19,13 +20,7 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
|||||||
isLoading && "bg-transparent",
|
isLoading && "bg-transparent",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!isLoading && avatarUrl && (
|
{!isLoading && avatarUrl && <Avatar src={avatarUrl} />}
|
||||||
<img
|
|
||||||
src={avatarUrl}
|
|
||||||
alt="user avatar"
|
|
||||||
className="w-full h-full rounded-full"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!isLoading && !avatarUrl && (
|
{!isLoading && !avatarUrl && (
|
||||||
<DefaultUserAvatar
|
<DefaultUserAvatar
|
||||||
aria-label="user avatar placeholder"
|
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 Lightbulb from "#/icons/lightbulb.svg?react";
|
||||||
import Refresh from "#/icons/refresh.svg?react";
|
|
||||||
|
|
||||||
interface SuggestionBubbleProps {
|
interface SuggestionBubbleProps {
|
||||||
suggestion: string;
|
suggestion: string;
|
||||||
@@ -12,6 +12,11 @@ export function SuggestionBubble({
|
|||||||
onClick,
|
onClick,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: SuggestionBubbleProps) {
|
}: SuggestionBubbleProps) {
|
||||||
|
const handleRefresh = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -21,15 +26,7 @@ export function SuggestionBubble({
|
|||||||
<Lightbulb width={18} height={18} />
|
<Lightbulb width={18} height={18} />
|
||||||
<span className="text-sm">{suggestion}</span>
|
<span className="text-sm">{suggestion}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<RefreshButton onClick={handleRefresh} />
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRefresh();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Refresh width={14} height={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { RootState } from "#/store";
|
import { RootState } from "#/store";
|
||||||
import { useTerminal } from "../../hooks/use-terminal";
|
import { useTerminal } from "#/hooks/use-terminal";
|
||||||
|
|
||||||
import "@xterm/xterm/css/xterm.css";
|
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 clsx from "clsx";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { NavTab } from "./nav-tab";
|
||||||
function BetaBadge() {
|
|
||||||
return (
|
|
||||||
<span className="text-[11px] leading-5 text-root-primary bg-neutral-400 px-1 rounded-xl">
|
|
||||||
Beta
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContainerProps {
|
interface ContainerProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -38,23 +30,7 @@ export function Container({
|
|||||||
{labels && (
|
{labels && (
|
||||||
<div className="flex text-xs h-[36px]">
|
<div className="flex text-xs h-[36px]">
|
||||||
{labels.map(({ label: l, to, icon, isBeta }) => (
|
{labels.map(({ label: l, to, icon, isBeta }) => (
|
||||||
<NavLink
|
<NavTab key={to} to={to} label={l} icon={icon} isBeta={isBeta} />
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</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
Reference in New Issue
Block a user