diff --git a/frontend/src/components/features/served-host/path-form.tsx b/frontend/src/components/features/served-host/path-form.tsx new file mode 100644 index 000000000000..daec8b7c7ef6 --- /dev/null +++ b/frontend/src/components/features/served-host/path-form.tsx @@ -0,0 +1,19 @@ +interface PathFormProps { + ref: React.RefObject; + onBlur: () => void; + defaultValue: string; +} + +export function PathForm({ ref, onBlur, defaultValue }: PathFormProps) { + return ( +
e.preventDefault()} className="flex-1"> + +
+ ); +} diff --git a/frontend/src/components/layout/served-app-label.tsx b/frontend/src/components/layout/served-app-label.tsx new file mode 100644 index 000000000000..824b3f3608f7 --- /dev/null +++ b/frontend/src/components/layout/served-app-label.tsx @@ -0,0 +1,12 @@ +import { useActiveHost } from "#/hooks/query/use-active-host"; + +export function ServedAppLabel() { + const { activeHost } = useActiveHost(); + + return ( +
+
App
+ {activeHost &&
} +
+ ); +} diff --git a/frontend/src/entry.client.tsx b/frontend/src/entry.client.tsx index 125833618e34..ba17326b4699 100644 --- a/frontend/src/entry.client.tsx +++ b/frontend/src/entry.client.tsx @@ -50,10 +50,13 @@ async function prepareApp() { } } +const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts"]; const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error, query) => { - if (!query.queryKey.includes("authenticated")) toast.error(error.message); + if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) { + toast.error(error.message); + } }, }), defaultOptions: { diff --git a/frontend/src/hooks/query/use-active-host.ts b/frontend/src/hooks/query/use-active-host.ts new file mode 100644 index 000000000000..6a5f8ec017dc --- /dev/null +++ b/frontend/src/hooks/query/use-active-host.ts @@ -0,0 +1,51 @@ +import { useQueries, useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import React from "react"; +import { useSelector } from "react-redux"; +import { openHands } from "#/api/open-hands-axios"; +import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state"; +import { RootState } from "#/store"; +import { useConversation } from "#/context/conversation-context"; + +export const useActiveHost = () => { + const { curAgentState } = useSelector((state: RootState) => state.agent); + const [activeHost, setActiveHost] = React.useState(null); + + const { conversationId } = useConversation(); + + const { data } = useQuery({ + queryKey: [conversationId, "hosts"], + queryFn: async () => { + const response = await openHands.get<{ hosts: string[] }>( + `/api/conversations/${conversationId}/web-hosts`, + ); + return { hosts: Object.keys(response.data.hosts) }; + }, + enabled: !RUNTIME_INACTIVE_STATES.includes(curAgentState), + initialData: { hosts: [] }, + }); + + const apps = useQueries({ + queries: data.hosts.map((host) => ({ + queryKey: [conversationId, "hosts", host], + queryFn: async () => { + try { + await axios.get(host); + return host; + } catch (e) { + return ""; + } + }, + refetchInterval: 3000, + })), + }); + + const appsData = apps.map((app) => app.data); + + React.useEffect(() => { + const successfulApp = appsData.find((app) => app); + setActiveHost(successfulApp || ""); + }, [appsData]); + + return { activeHost }; +}; diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 75c88ff78a7c..53305537a0b8 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -12,6 +12,7 @@ export default [ index("routes/_oh.app._index/route.tsx"), route("browser", "routes/_oh.app.browser.tsx"), route("jupyter", "routes/_oh.app.jupyter.tsx"), + route("served", "routes/app.tsx"), ]), ]), diff --git a/frontend/src/routes/_oh.app/route.tsx b/frontend/src/routes/_oh.app/route.tsx index 086d3f0f5e23..182a7a98e57c 100644 --- a/frontend/src/routes/_oh.app/route.tsx +++ b/frontend/src/routes/_oh.app/route.tsx @@ -2,6 +2,7 @@ import { useDisclosure } from "@nextui-org/react"; import React from "react"; import { Outlet } from "react-router"; import { useDispatch, useSelector } from "react-redux"; +import { FaServer } from "react-icons/fa"; import toast from "react-hot-toast"; import { ConversationProvider, @@ -31,6 +32,7 @@ import Security from "#/components/shared/modals/security/security"; import { useEndSession } from "#/hooks/use-end-session"; import { useUserConversation } from "#/hooks/query/use-user-conversation"; import { CountBadge } from "#/components/layout/count-badge"; +import { ServedAppLabel } from "#/components/layout/served-app-label"; import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label"; import { useSettings } from "#/hooks/query/use-settings"; import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags"; @@ -126,6 +128,11 @@ function AppContent() { labels={[ { label: "Workspace", to: "", icon: }, { label: "Jupyter", to: "jupyter", icon: }, + { + label: , + to: "served", + icon: , + }, { label: (
diff --git a/frontend/src/routes/app.tsx b/frontend/src/routes/app.tsx new file mode 100644 index 000000000000..e44ac6947d36 --- /dev/null +++ b/frontend/src/routes/app.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { FaArrowRotateRight } from "react-icons/fa6"; +import { FaExternalLinkAlt, FaHome } from "react-icons/fa"; +import { useActiveHost } from "#/hooks/query/use-active-host"; +import { PathForm } from "#/components/features/served-host/path-form"; + +function ServedApp() { + const { activeHost } = useActiveHost(); + const [refreshKey, setRefreshKey] = React.useState(0); + const [currentActiveHost, setCurrentActiveHost] = React.useState< + string | null + >(null); + const [path, setPath] = React.useState("hello"); + + const formRef = React.useRef(null); + + const handleOnBlur = () => { + if (formRef.current) { + const formData = new FormData(formRef.current); + const urlInputValue = formData.get("url")?.toString(); + + if (urlInputValue) { + const url = new URL(urlInputValue); + + setCurrentActiveHost(url.origin); + setPath(url.pathname); + } + } + }; + + const resetUrl = () => { + setCurrentActiveHost(activeHost); + setPath(""); + + if (formRef.current) { + formRef.current.reset(); + } + }; + + React.useEffect(() => { + resetUrl(); + }, [activeHost]); + + const fullUrl = `${currentActiveHost}/${path}`; + + if (!currentActiveHost) { + return ( +
+ + If you tell OpenHands to start a web server, the app will appear here. + +
+ ); + } + + return ( +
+
+ + + + +
+ +
+
+