fix(frontend): The conversation page cannot be used on mobile devices and tablets. (#9558)

This commit is contained in:
Hiep Le
2025-07-12 00:43:53 +07:00
committed by GitHub
parent a266d4274a
commit c03d390772
7 changed files with 282 additions and 113 deletions

View File

@@ -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>
);
}

View File

@@ -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>
)}

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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();
};
}, []);
};

View File

@@ -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 />}
/>
);
}