mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
fix(frontend): The conversation page cannot be used on mobile devices and tablets. (#9558)
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { DiGit } from "react-icons/di";
|
||||
import { FaServer, FaExternalLinkAlt } from "react-icons/fa";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { VscCode } from "react-icons/vsc";
|
||||
import { Container } from "#/components/layout/container";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RootState } from "#/store";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { ServedAppLabel } from "#/components/layout/served-app-label";
|
||||
import { TabContent } from "#/components/layout/tab-content";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import GlobeIcon from "#/icons/globe.svg?react";
|
||||
import JupyterIcon from "#/icons/jupyter.svg?react";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import TerminalIcon from "#/icons/terminal.svg?react";
|
||||
|
||||
export function ConversationTabs() {
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const basePath = `/conversations/${conversationId}`;
|
||||
|
||||
return (
|
||||
<Container
|
||||
className="h-full w-full"
|
||||
labels={[
|
||||
{
|
||||
label: "Changes",
|
||||
to: "",
|
||||
icon: <DiGit className="w-6 h-6" />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="flex items-center gap-1">
|
||||
{t(I18nKey.VSCODE$TITLE)}
|
||||
</div>
|
||||
),
|
||||
to: "vscode",
|
||||
icon: <VscCode className="w-5 h-5" />,
|
||||
rightContent: !RUNTIME_INACTIVE_STATES.includes(curAgentState) ? (
|
||||
<FaExternalLinkAlt
|
||||
className="w-3 h-3 text-neutral-400 cursor-pointer"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (conversationId) {
|
||||
try {
|
||||
const data = await OpenHands.getVSCodeUrl(conversationId);
|
||||
if (data.vscode_url) {
|
||||
const transformedUrl = transformVSCodeUrl(
|
||||
data.vscode_url,
|
||||
);
|
||||
if (transformedUrl) {
|
||||
window.open(transformedUrl, "_blank");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently handle the error
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
label: t(I18nKey.WORKSPACE$TERMINAL_TAB_LABEL),
|
||||
to: "terminal",
|
||||
icon: <TerminalIcon />,
|
||||
},
|
||||
{ label: "Jupyter", to: "jupyter", icon: <JupyterIcon /> },
|
||||
{
|
||||
label: <ServedAppLabel />,
|
||||
to: "served",
|
||||
icon: <FaServer />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="flex items-center gap-1">
|
||||
{t(I18nKey.BROWSER$TITLE)}
|
||||
</div>
|
||||
),
|
||||
to: "browser",
|
||||
icon: <GlobeIcon />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Use both Outlet and TabContent */}
|
||||
<div className="h-full w-full">
|
||||
<TabContent conversationPath={basePath} />
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { NavTab } from "./nav-tab";
|
||||
import { ScrollLeftButton } from "./scroll-left-button";
|
||||
import { ScrollRightButton } from "./scroll-right-button";
|
||||
import { useTrackElementWidth } from "#/hooks/use-track-element-width";
|
||||
|
||||
interface ContainerProps {
|
||||
label?: React.ReactNode;
|
||||
@@ -22,27 +25,96 @@ export function Container({
|
||||
children,
|
||||
className,
|
||||
}: ContainerProps) {
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track container width using ResizeObserver
|
||||
useTrackElementWidth({
|
||||
elementRef: containerRef,
|
||||
callback: setContainerWidth,
|
||||
});
|
||||
|
||||
// Check scroll position and update button states
|
||||
const updateScrollButtons = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } =
|
||||
scrollContainerRef.current;
|
||||
setCanScrollLeft(scrollLeft > 0);
|
||||
setCanScrollRight(scrollLeft < scrollWidth - clientWidth);
|
||||
}
|
||||
};
|
||||
|
||||
// Update scroll buttons when tabs change or container width changes
|
||||
useEffect(() => {
|
||||
updateScrollButtons();
|
||||
}, [labels, containerWidth]);
|
||||
|
||||
// Scroll functions
|
||||
const scrollLeft = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollBy({ left: -200, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
const scrollRight = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollBy({ left: 200, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
const showScrollButtons = containerWidth < 598 && labels && labels.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
"bg-base-secondary border border-neutral-600 rounded-xl flex flex-col h-full",
|
||||
"bg-base-secondary border border-neutral-600 rounded-xl flex flex-col h-full w-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{labels && (
|
||||
<div className="flex text-xs h-[36px]">
|
||||
{labels.map(
|
||||
({ label: l, to, icon, isBeta, isLoading, rightContent }) => (
|
||||
<NavTab
|
||||
key={to}
|
||||
to={to}
|
||||
label={l}
|
||||
icon={icon}
|
||||
isBeta={isBeta}
|
||||
isLoading={isLoading}
|
||||
rightContent={rightContent}
|
||||
/>
|
||||
),
|
||||
<div className="relative flex items-center h-[36px] w-full">
|
||||
{/* Left scroll button */}
|
||||
{showScrollButtons && (
|
||||
<ScrollLeftButton
|
||||
scrollLeft={scrollLeft}
|
||||
canScrollLeft={canScrollLeft}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scrollable tabs container */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={clsx(
|
||||
"flex text-xs overflow-x-auto scrollbar-hide w-full",
|
||||
showScrollButtons && "mx-8",
|
||||
)}
|
||||
onScroll={updateScrollButtons}
|
||||
>
|
||||
{labels.map(
|
||||
({ label: l, to, icon, isBeta, isLoading, rightContent }) => (
|
||||
<NavTab
|
||||
key={to}
|
||||
to={to}
|
||||
label={l}
|
||||
icon={icon}
|
||||
isBeta={isBeta}
|
||||
isLoading={isLoading}
|
||||
rightContent={rightContent}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right scroll button */}
|
||||
{showScrollButtons && (
|
||||
<ScrollRightButton
|
||||
scrollRight={scrollRight}
|
||||
canScrollRight={canScrollRight}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -33,12 +33,12 @@ export function NavTab({
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<div className={cn(isActive && "text-logo")}>{icon}</div>
|
||||
{label}
|
||||
<span className="truncate">{label}</span>
|
||||
{isBeta && <BetaBadge />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{rightContent}
|
||||
{isLoading && <LoadingSpinner size="small" />}
|
||||
</div>
|
||||
|
||||
27
frontend/src/components/layout/scroll-left-button.tsx
Normal file
27
frontend/src/components/layout/scroll-left-button.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import clsx from "clsx";
|
||||
import { ChevronLeft } from "../../assets/chevron-left";
|
||||
|
||||
interface ScrollLeftButtonProps {
|
||||
scrollLeft: () => void;
|
||||
canScrollLeft: boolean;
|
||||
}
|
||||
|
||||
export function ScrollLeftButton({
|
||||
scrollLeft,
|
||||
canScrollLeft,
|
||||
}: ScrollLeftButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollLeft}
|
||||
disabled={!canScrollLeft}
|
||||
className={clsx(
|
||||
"cursor-pointer absolute left-0 z-10 bg-base-secondary border-r border-neutral-600 h-full px-2 flex items-center justify-center",
|
||||
"hover:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"rounded-tl-xl",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft width={16} height={16} active={canScrollLeft} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/layout/scroll-right-button.tsx
Normal file
27
frontend/src/components/layout/scroll-right-button.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import clsx from "clsx";
|
||||
import { ChevronRight } from "../../assets/chevron-right";
|
||||
|
||||
interface ScrollRightButtonProps {
|
||||
scrollRight: () => void;
|
||||
canScrollRight: boolean;
|
||||
}
|
||||
|
||||
export function ScrollRightButton({
|
||||
scrollRight,
|
||||
canScrollRight,
|
||||
}: ScrollRightButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollRight}
|
||||
disabled={!canScrollRight}
|
||||
className={clsx(
|
||||
"cursor-pointer absolute right-0 z-10 bg-base-secondary border-l border-neutral-600 h-full px-2 flex items-center justify-center",
|
||||
"hover:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"rounded-tr-xl",
|
||||
)}
|
||||
>
|
||||
<ChevronRight width={16} height={16} active={canScrollRight} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
27
frontend/src/hooks/use-track-element-width.ts
Normal file
27
frontend/src/hooks/use-track-element-width.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface UseTrackElementWidthProps {
|
||||
elementRef: React.RefObject<HTMLElement | null>;
|
||||
callback: (width: number) => void;
|
||||
}
|
||||
|
||||
export const useTrackElementWidth = ({
|
||||
elementRef,
|
||||
callback,
|
||||
}: UseTrackElementWidthProps) => {
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
callback(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
if (elementRef.current) {
|
||||
resizeObserver.observe(elementRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -1,55 +1,42 @@
|
||||
import { useDisclosure } from "@heroui/react";
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { FaServer, FaExternalLinkAlt } from "react-icons/fa";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DiGit } from "react-icons/di";
|
||||
import { VscCode } from "react-icons/vsc";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { Controls } from "#/components/features/controls/controls";
|
||||
import { clearTerminal } from "#/state/command-slice";
|
||||
import { useEffectOnce } from "#/hooks/use-effect-once";
|
||||
import GlobeIcon from "#/icons/globe.svg?react";
|
||||
import JupyterIcon from "#/icons/jupyter.svg?react";
|
||||
import TerminalIcon from "#/icons/terminal.svg?react";
|
||||
import { clearJupyter } from "#/state/jupyter-slice";
|
||||
|
||||
import { ChatInterface } from "../components/features/chat/chat-interface";
|
||||
import { WsClientProvider } from "#/context/ws-client-provider";
|
||||
import { EventHandler } from "../wrapper/event-handler";
|
||||
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
|
||||
import { Container } from "#/components/layout/container";
|
||||
|
||||
import {
|
||||
Orientation,
|
||||
ResizablePanel,
|
||||
} from "#/components/layout/resizable-panel";
|
||||
import Security from "#/components/shared/modals/security/security";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { ServedAppLabel } from "#/components/layout/served-app-label";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { RootState } from "#/store";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useDocumentTitleFromState } from "#/hooks/use-document-title-from-state";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { TabContent } from "#/components/layout/tab-content";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs";
|
||||
|
||||
function AppContent() {
|
||||
useConversationConfig();
|
||||
const { t } = useTranslation();
|
||||
const { data: settings } = useSettings();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation, isFetched, refetch } = useActiveConversation();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -100,12 +87,15 @@ function AppContent() {
|
||||
} = useDisclosure();
|
||||
|
||||
function renderMain() {
|
||||
const basePath = `/conversations/${conversationId}`;
|
||||
|
||||
if (width <= 640) {
|
||||
if (width <= 1024) {
|
||||
return (
|
||||
<div className="rounded-xl overflow-hidden border border-neutral-600 w-full bg-base-secondary">
|
||||
<ChatInterface />
|
||||
<div className="flex flex-col gap-3 overflow-auto w-full">
|
||||
<div className="rounded-xl overflow-hidden border border-neutral-600 w-full bg-base-secondary min-h-[494px]">
|
||||
<ChatInterface />
|
||||
</div>
|
||||
<div className="h-full w-full min-h-[494px]">
|
||||
<ConversationTabs />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -117,79 +107,7 @@ function AppContent() {
|
||||
firstClassName="rounded-xl overflow-hidden border border-neutral-600 bg-base-secondary"
|
||||
secondClassName="flex flex-col overflow-hidden"
|
||||
firstChild={<ChatInterface />}
|
||||
secondChild={
|
||||
<Container
|
||||
className="h-full w-full"
|
||||
labels={[
|
||||
{
|
||||
label: "Changes",
|
||||
to: "",
|
||||
icon: <DiGit className="w-6 h-6" />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="flex items-center gap-1">
|
||||
{t(I18nKey.VSCODE$TITLE)}
|
||||
</div>
|
||||
),
|
||||
to: "vscode",
|
||||
icon: <VscCode className="w-5 h-5" />,
|
||||
rightContent: !RUNTIME_INACTIVE_STATES.includes(
|
||||
curAgentState,
|
||||
) ? (
|
||||
<FaExternalLinkAlt
|
||||
className="w-3 h-3 text-neutral-400 cursor-pointer"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (conversationId) {
|
||||
try {
|
||||
const data =
|
||||
await OpenHands.getVSCodeUrl(conversationId);
|
||||
if (data.vscode_url) {
|
||||
const transformedUrl = transformVSCodeUrl(
|
||||
data.vscode_url,
|
||||
);
|
||||
if (transformedUrl) {
|
||||
window.open(transformedUrl, "_blank");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently handle the error
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
label: t(I18nKey.WORKSPACE$TERMINAL_TAB_LABEL),
|
||||
to: "terminal",
|
||||
icon: <TerminalIcon />,
|
||||
},
|
||||
{ label: "Jupyter", to: "jupyter", icon: <JupyterIcon /> },
|
||||
{
|
||||
label: <ServedAppLabel />,
|
||||
to: "served",
|
||||
icon: <FaServer />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="flex items-center gap-1">
|
||||
{t(I18nKey.BROWSER$TITLE)}
|
||||
</div>
|
||||
),
|
||||
to: "browser",
|
||||
icon: <GlobeIcon />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Use both Outlet and TabContent */}
|
||||
<div className="h-full w-full">
|
||||
<TabContent conversationPath={basePath} />
|
||||
</div>
|
||||
</Container>
|
||||
}
|
||||
secondChild={<ConversationTabs />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user