From b45fc522c72c8cc78738a0093615b5bf1038ac26 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Thu, 9 Jan 2025 18:55:33 +0400 Subject: [PATCH] feat(frontend): Display current conversation info in the bottom right (#6143) --- .../conversation-card.test.tsx | 115 ++++++++++++++++++ .../components/features/controls/controls.tsx | 55 +++++---- .../conversation-card-context-menu.tsx | 36 ++++-- .../conversation-panel/conversation-card.tsx | 88 +++++++++----- .../features/project-menu/ProjectMenuCard.tsx | 92 -------------- .../project-menu-details-placeholder.tsx | 41 ------- .../project-menu/project-menu-details.tsx | 52 -------- .../project.menu-card-context-menu.tsx | 37 ------ .../components/features/sidebar/sidebar.tsx | 2 +- ...ermissions.ts => use-user-conversation.ts} | 0 frontend/src/routes/_oh.app/route.tsx | 34 ++---- frontend/tests/conversation-panel.test.ts | 26 ++++ 12 files changed, 265 insertions(+), 313 deletions(-) delete mode 100644 frontend/src/components/features/project-menu/ProjectMenuCard.tsx delete mode 100644 frontend/src/components/features/project-menu/project-menu-details-placeholder.tsx delete mode 100644 frontend/src/components/features/project-menu/project-menu-details.tsx delete mode 100644 frontend/src/components/features/project-menu/project.menu-card-context-menu.tsx rename frontend/src/hooks/query/{get-conversation-permissions.ts => use-user-conversation.ts} (100%) diff --git a/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx b/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx index abad655ae249..9a1e105f5357 100644 --- a/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx +++ b/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx @@ -9,6 +9,7 @@ describe("ConversationCard", () => { const onClick = vi.fn(); const onDelete = vi.fn(); const onChangeTitle = vi.fn(); + const onDownloadWorkspace = vi.fn(); afterEach(() => { vi.clearAllMocks(); @@ -233,6 +234,120 @@ describe("ConversationCard", () => { expect(onClick).not.toHaveBeenCalled(); }); + it("should call onDownloadWorkspace when the download button is clicked", async () => { + const user = userEvent.setup(); + render( + , + ); + + const ellipsisButton = screen.getByTestId("ellipsis-button"); + await user.click(ellipsisButton); + + const menu = screen.getByTestId("context-menu"); + const downloadButton = within(menu).getByTestId("download-button"); + + await user.click(downloadButton); + + expect(onDownloadWorkspace).toHaveBeenCalled(); + }); + + it("should not display the edit or delete options if the handler is not provided", async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + ); + + const ellipsisButton = screen.getByTestId("ellipsis-button"); + await user.click(ellipsisButton); + + expect(screen.queryByTestId("edit-button")).toBeInTheDocument(); + expect(screen.queryByTestId("delete-button")).not.toBeInTheDocument(); + + // toggle to hide the context menu + await user.click(ellipsisButton); + + rerender( + , + ); + + await user.click(ellipsisButton); + + expect(screen.queryByTestId("edit-button")).not.toBeInTheDocument(); + expect(screen.queryByTestId("delete-button")).toBeInTheDocument(); + }); + + it("should not render the ellipsis button if there are no actions", () => { + const { rerender } = render( + , + ); + + expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.queryByTestId("ellipsis-button")).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.queryByTestId("ellipsis-button")).not.toBeInTheDocument(); + }); + describe("state indicator", () => { it("should render the 'STOPPED' indicator by default", () => { render( diff --git a/frontend/src/components/features/controls/controls.tsx b/frontend/src/components/features/controls/controls.tsx index 994a5f5101db..0984dae1e9bd 100644 --- a/frontend/src/components/features/controls/controls.tsx +++ b/frontend/src/components/features/controls/controls.tsx @@ -1,39 +1,30 @@ +import { useParams } from "react-router"; import React from "react"; -import { useSelector } from "react-redux"; +import posthog from "posthog-js"; import { AgentControlBar } from "./agent-control-bar"; import { AgentStatusBar } from "./agent-status-bar"; -import { ProjectMenuCard } from "../project-menu/ProjectMenuCard"; -import { useAuth } from "#/context/auth-context"; -import { RootState } from "#/store"; import { SecurityLock } from "./security-lock"; +import { useUserConversation } from "#/hooks/query/use-user-conversation"; +import { ConversationCard } from "../conversation-panel/conversation-card"; +import { DownloadModal } from "#/components/shared/download-modal"; interface ControlsProps { setSecurityOpen: (isOpen: boolean) => void; showSecurityLock: boolean; - lastCommitData: GitHubCommit | null; } -export function Controls({ - setSecurityOpen, - showSecurityLock, - lastCommitData, -}: ControlsProps) { - const { gitHubToken } = useAuth(); - const { selectedRepository } = useSelector( - (state: RootState) => state.initialQuery, +export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) { + const params = useParams(); + const { data: conversation } = useUserConversation( + params.conversationId ?? null, ); - const projectMenuCardData = React.useMemo( - () => - selectedRepository && lastCommitData - ? { - repoName: selectedRepository, - lastCommit: lastCommitData, - avatar: null, // TODO: fetch repo avatar - } - : null, - [selectedRepository, lastCommitData], - ); + const [downloading, setDownloading] = React.useState(false); + + const handleDownloadWorkspace = () => { + posthog.capture("download_workspace_button_clicked"); + setDownloading(true); + }; return (
@@ -46,9 +37,19 @@ export function Controls({ )}
- + + setDownloading(false)} + isOpen={downloading} /> ); diff --git a/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx b/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx index d22c74cbb14e..33088c72f34d 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx @@ -1,17 +1,22 @@ import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; +import { cn } from "#/utils/utils"; import { ContextMenu } from "../context-menu/context-menu"; import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; interface ConversationCardContextMenuProps { onClose: () => void; - onDelete: (event: React.MouseEvent) => void; - onEdit: (event: React.MouseEvent) => void; + onDelete?: (event: React.MouseEvent) => void; + onEdit?: (event: React.MouseEvent) => void; + onDownload?: (event: React.MouseEvent) => void; + position?: "top" | "bottom"; } export function ConversationCardContextMenu({ onClose, onDelete, onEdit, + onDownload, + position = "bottom", }: ConversationCardContextMenuProps) { const ref = useClickOutsideElement(onClose); @@ -19,14 +24,27 @@ export function ConversationCardContextMenu({ - - Delete - - - Edit Title - + {onDelete && ( + + Delete + + )} + {onEdit && ( + + Edit Title + + )} + {onDownload && ( + + Download Workspace + + )} ); } diff --git a/frontend/src/components/features/conversation-panel/conversation-card.tsx b/frontend/src/components/features/conversation-panel/conversation-card.tsx index e7c87e061554..a178ce13d024 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card.tsx @@ -7,25 +7,32 @@ import { } from "./conversation-state-indicator"; import { EllipsisButton } from "./ellipsis-button"; import { ConversationCardContextMenu } from "./conversation-card-context-menu"; +import { cn } from "#/utils/utils"; interface ConversationCardProps { - onDelete: () => void; - onChangeTitle: (title: string) => void; - isActive: boolean; + onClick?: () => void; + onDelete?: () => void; + onChangeTitle?: (title: string) => void; + onDownloadWorkspace?: () => void; + isActive?: boolean; title: string; selectedRepository: string | null; lastUpdatedAt: string; // ISO 8601 status?: ProjectStatus; + variant?: "compact" | "default"; } export function ConversationCard({ + onClick, onDelete, onChangeTitle, + onDownloadWorkspace, isActive, title, selectedRepository, lastUpdatedAt, status = "STOPPED", + variant = "default", }: ConversationCardProps) { const [contextMenuVisible, setContextMenuVisible] = React.useState(false); const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view"); @@ -34,7 +41,7 @@ export function ConversationCard({ const handleBlur = () => { if (inputRef.current?.value) { const trimmed = inputRef.current.value.trim(); - onChangeTitle(trimmed); + onChangeTitle?.(trimmed); inputRef.current!.value = trimmed; } else { // reset the value if it's empty @@ -58,7 +65,7 @@ export function ConversationCard({ const handleDelete = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); - onDelete(); + onDelete?.(); }; const handleEdit = (event: React.MouseEvent) => { @@ -68,16 +75,28 @@ export function ConversationCard({ setContextMenuVisible(false); }; + const handleDownload = (event: React.MouseEvent) => { + event.stopPropagation(); + onDownloadWorkspace?.(); + }; + React.useEffect(() => { if (titleMode === "edit") { inputRef.current?.focus(); } }, [titleMode]); + const hasContextMenu = !!(onDelete || onChangeTitle || onDownloadWorkspace); + return (
@@ -97,31 +116,42 @@ export function ConversationCard({
- { - event.preventDefault(); - event.stopPropagation(); - setContextMenuVisible((prev) => !prev); - }} - /> + {hasContextMenu && ( + { + event.preventDefault(); + event.stopPropagation(); + setContextMenuVisible((prev) => !prev); + }} + /> + )} + {contextMenuVisible && ( + setContextMenuVisible(false)} + onDelete={onDelete && handleDelete} + onEdit={onChangeTitle && handleEdit} + onDownload={onDownloadWorkspace && handleDownload} + position={variant === "compact" ? "top" : "bottom"} + /> + )}
- {contextMenuVisible && ( - setContextMenuVisible(false)} - onDelete={handleDelete} - onEdit={handleEdit} - /> - )} - {selectedRepository && ( - e.stopPropagation()} - /> - )} -

- -

+ +
+ {selectedRepository && ( + e.stopPropagation()} + /> + )} +

+ +

+
); } diff --git a/frontend/src/components/features/project-menu/ProjectMenuCard.tsx b/frontend/src/components/features/project-menu/ProjectMenuCard.tsx deleted file mode 100644 index bebf4a5ae921..000000000000 --- a/frontend/src/components/features/project-menu/ProjectMenuCard.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React from "react"; -import posthog from "posthog-js"; -import { useTranslation } from "react-i18next"; -import EllipsisH from "#/icons/ellipsis-h.svg?react"; -import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu"; -import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder"; -import { ProjectMenuDetails } from "./project-menu-details"; -import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal"; -import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; -import { DownloadModal } from "#/components/shared/download-modal"; -import { I18nKey } from "#/i18n/declaration"; - -interface ProjectMenuCardProps { - isConnectedToGitHub: boolean; - githubData: { - avatar: string | null; - repoName: string; - lastCommit: GitHubCommit; - } | null; -} - -export function ProjectMenuCard({ - isConnectedToGitHub, - githubData, -}: ProjectMenuCardProps) { - const { t } = useTranslation(); - - const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false); - const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] = - React.useState(false); - const [downloading, setDownloading] = React.useState(false); - - const toggleMenuVisibility = () => { - setContextMenuIsOpen((prev) => !prev); - }; - - const handleDownloadWorkspace = () => { - posthog.capture("download_workspace_button_clicked"); - setDownloading(true); - }; - - const handleDownloadClose = () => { - setDownloading(false); - }; - - return ( -
- {!downloading && contextMenuIsOpen && ( - setConnectToGitHubModalOpen(true)} - onDownloadWorkspace={handleDownloadWorkspace} - onClose={() => setContextMenuIsOpen(false)} - /> - )} - {githubData && ( - - )} - {!githubData && ( - setConnectToGitHubModalOpen(true)} - /> - )} - - {!downloading && ( - - )} - {connectToGitHubModalOpen && ( - setConnectToGitHubModalOpen(false)}> - setConnectToGitHubModalOpen(false)} - /> - - )} -
- ); -} diff --git a/frontend/src/components/features/project-menu/project-menu-details-placeholder.tsx b/frontend/src/components/features/project-menu/project-menu-details-placeholder.tsx deleted file mode 100644 index 47a6a381809c..000000000000 --- a/frontend/src/components/features/project-menu/project-menu-details-placeholder.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { cn } from "#/utils/utils"; -import CloudConnection from "#/icons/cloud-connection.svg?react"; -import { I18nKey } from "#/i18n/declaration"; - -interface ProjectMenuDetailsPlaceholderProps { - isConnectedToGitHub: boolean; - onConnectToGitHub: () => void; -} - -export function ProjectMenuDetailsPlaceholder({ - isConnectedToGitHub, - onConnectToGitHub, -}: ProjectMenuDetailsPlaceholderProps) { - const { t } = useTranslation(); - - return ( -
- - {t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$NEW_PROJECT_LABEL)} - - -
- ); -} diff --git a/frontend/src/components/features/project-menu/project-menu-details.tsx b/frontend/src/components/features/project-menu/project-menu-details.tsx deleted file mode 100644 index 3766d00e30c0..000000000000 --- a/frontend/src/components/features/project-menu/project-menu-details.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useTranslation } from "react-i18next"; -import ExternalLinkIcon from "#/icons/external-link.svg?react"; -import { formatTimeDelta } from "#/utils/format-time-delta"; -import { I18nKey } from "#/i18n/declaration"; - -interface ProjectMenuDetailsProps { - repoName: string; - avatar: string | null; - lastCommit: GitHubCommit; -} - -export function ProjectMenuDetails({ - repoName, - avatar, - lastCommit, -}: ProjectMenuDetailsProps) { - const { t } = useTranslation(); - return ( - - ); -} diff --git a/frontend/src/components/features/project-menu/project.menu-card-context-menu.tsx b/frontend/src/components/features/project-menu/project.menu-card-context-menu.tsx deleted file mode 100644 index d476ffdaf932..000000000000 --- a/frontend/src/components/features/project-menu/project.menu-card-context-menu.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; -import { ContextMenu } from "../context-menu/context-menu"; -import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; -import { I18nKey } from "#/i18n/declaration"; - -interface ProjectMenuCardContextMenuProps { - isConnectedToGitHub: boolean; - onConnectToGitHub: () => void; - onDownloadWorkspace: () => void; - onClose: () => void; -} - -export function ProjectMenuCardContextMenu({ - isConnectedToGitHub, - onConnectToGitHub, - onDownloadWorkspace, - onClose, -}: ProjectMenuCardContextMenuProps) { - const menuRef = useClickOutsideElement(onClose); - const { t } = useTranslation(); - return ( - - {!isConnectedToGitHub && ( - - {t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL)} - - )} - - {t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL)} - - - ); -} diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx index 6b7db215841a..f6a1728ce5ed 100644 --- a/frontend/src/components/features/sidebar/sidebar.tsx +++ b/frontend/src/components/features/sidebar/sidebar.tsx @@ -72,7 +72,7 @@ export function Sidebar() { {MULTI_CONVERSATION_UI && ( setConversationPanelIsOpen((prev) => !prev)} diff --git a/frontend/src/hooks/query/get-conversation-permissions.ts b/frontend/src/hooks/query/use-user-conversation.ts similarity index 100% rename from frontend/src/hooks/query/get-conversation-permissions.ts rename to frontend/src/hooks/query/use-user-conversation.ts diff --git a/frontend/src/routes/_oh.app/route.tsx b/frontend/src/routes/_oh.app/route.tsx index ab3384f951ec..086d3f0f5e23 100644 --- a/frontend/src/routes/_oh.app/route.tsx +++ b/frontend/src/routes/_oh.app/route.tsx @@ -20,7 +20,6 @@ import { FilesProvider } from "#/context/files"; import { ChatInterface } from "../../components/features/chat/chat-interface"; import { WsClientProvider } from "#/context/ws-client-provider"; import { EventHandler } from "./event-handler"; -import { useLatestRepoCommit } from "#/hooks/query/use-latest-repo-commit"; import { useAuth } from "#/context/auth-context"; import { useConversationConfig } from "#/hooks/query/use-conversation-config"; import { Container } from "#/components/layout/container"; @@ -30,38 +29,26 @@ import { } from "#/components/layout/resizable-panel"; import Security from "#/components/shared/modals/security/security"; import { useEndSession } from "#/hooks/use-end-session"; -import { useUserConversation } from "#/hooks/query/get-conversation-permissions"; +import { useUserConversation } from "#/hooks/query/use-user-conversation"; import { CountBadge } from "#/components/layout/count-badge"; import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label"; import { useSettings } from "#/hooks/query/use-settings"; import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags"; function AppContent() { + useConversationConfig(); const { gitHubToken } = useAuth(); const { data: settings } = useSettings(); - - const endSession = useEndSession(); - const [width, setWidth] = React.useState(window.innerWidth); - const { conversationId } = useConversation(); - - const dispatch = useDispatch(); - - useConversationConfig(); const { data: conversation, isFetched } = useUserConversation( conversationId || null, ); + const dispatch = useDispatch(); + const endSession = useEndSession(); - const { selectedRepository } = useSelector( - (state: RootState) => state.initialQuery, - ); - + const [width, setWidth] = React.useState(window.innerWidth); const { updateCount } = useSelector((state: RootState) => state.browser); - const { data: latestGitHubCommit } = useLatestRepoCommit({ - repository: selectedRepository, - }); - const secrets = React.useMemo( () => [gitHubToken].filter((secret) => secret !== null), [gitHubToken], @@ -180,13 +167,10 @@ function AppContent() {
{renderMain()}
-
- -
+ { test.beforeEach(async ({ page }) => { await page.goto("/"); await page.evaluate(() => { + localStorage.setItem("FEATURE_MULTI_CONVERSATION_UI", "true"); localStorage.setItem("analytics-consent", "true"); localStorage.setItem("SETTINGS_VERSION", "5"); }); @@ -111,3 +112,28 @@ test("should redirect to home screen if conversation deos not exist", async ({ await page.goto("/conversations/9999"); await page.waitForURL("/"); }); + +test("display the conversation details during a conversation", async ({ + page, +}) => { + const conversationPanelButton = page.getByTestId("toggle-conversation-panel"); + await expect(conversationPanelButton).toBeVisible(); + await conversationPanelButton.click(); + + const panel = page.getByTestId("conversation-panel"); + + // select a conversation + const conversationItem = panel.getByTestId("conversation-card").first(); + await conversationItem.click(); + + // panel should close + await expect(panel).not.toBeVisible(); + + await page.waitForURL("/conversations/1"); + expect(page.url()).toBe("http://localhost:3001/conversations/1"); + + const conversationDetails = page.getByTestId("conversation-card"); + + await expect(conversationDetails).toBeVisible(); + await expect(conversationDetails).toHaveText("Conversation 1"); +});