diff --git a/apps/web/src/components/agents/list.tsx b/apps/web/src/components/agents/list.tsx index 1e8b1b5988..6d00df2251 100644 --- a/apps/web/src/components/agents/list.tsx +++ b/apps/web/src/components/agents/list.tsx @@ -404,44 +404,47 @@ export default function List() { } - > - {!agents - ? ( -
- -
- ) - : agents.length > 0 - ? ( - <> -
- {filteredAgents?.map((agent) => ( - - ))} -
-
- + {!agents + ? ( +
+ +
+ ) + : agents.length > 0 + ? ( + <> +
+ {filteredAgents?.map((agent) => ( + + ))} +
+
+ +

+ No agents match your filter. Try adjusting your search. +

+
+ + ) + : ( + -

- No agents match your filter. Try adjusting your search. -

-
- - ) - : ( - - )} - + )} + + } + /> ); } diff --git a/apps/web/src/components/chat/Chat.tsx b/apps/web/src/components/chat/Chat.tsx index bdd6f37b55..3ddceb0f9f 100644 --- a/apps/web/src/components/chat/Chat.tsx +++ b/apps/web/src/components/chat/Chat.tsx @@ -326,20 +326,25 @@ export function Chat({ return ( -
-
- {messages.length === 0 ? : ( - - )} + main={ +
+
+ {messages.length === 0 ? : ( + + )} +
-
- + } + /> ); } @@ -370,21 +375,22 @@ export function Chat({ />
} - > -
-
- {messages.length === 0 ? : ( - - )} + main={ +
+
+ {messages.length === 0 ? : ( + + )} +
+
-
-
- + } + /> ); } diff --git a/apps/web/src/components/dock/index.tsx b/apps/web/src/components/dock/index.tsx new file mode 100644 index 0000000000..50f4d7bc13 --- /dev/null +++ b/apps/web/src/components/dock/index.tsx @@ -0,0 +1,246 @@ +import { Button } from "@deco/ui/components/button.tsx"; +import { Icon } from "@deco/ui/components/icon.tsx"; +import { ScrollArea } from "@deco/ui/components/scroll-area.tsx"; +import { Spinner } from "@deco/ui/components/spinner.tsx"; +import { + AddPanelOptions, + type DockviewApi, + DockviewReact, + type DockviewReadyEvent, + IDockviewPanelHeaderProps, + type IDockviewPanelProps, +} from "dockview-react"; +import { + ComponentProps, + ComponentType, + createContext, + Suspense, + use, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; + +const Context = createContext< + { + mainViewName: string; + openPanels: Set; + availablePanels: Set; + } | null +>(null); + +export const useDock = () => { + const ctx = use(Context); + + if (!ctx) { + throw new Error("Dock context not found"); + } + + return ctx; +}; + +const adapter = + (Component: ComponentType) => + (props: IDockviewPanelProps) => { + const { mainViewName } = useDock(); + + return ( + + +
+ } + > + {props.api.component === mainViewName + ? + : ( + + + + )} + + ); + }; + +const TAB_COMPONENTS = { + default: (props: IDockviewPanelHeaderProps) => { + const { mainViewName } = useDock(); + + if (props.api.component === mainViewName) { + return null; + } + + return ( +
+

{props.api.title}

+ +
+ ); + }, +}; + +const channel = new EventTarget(); + +export const togglePanel = (detail: AddPanelOptions) => { + channel.dispatchEvent( + new CustomEvent("message", { detail }), + ); +}; + +type Props = + & Partial, "components">> + & { + mainView: ComponentType; + components: Record; + }; + +const NO_DROP_TARGET = "no-drop-target"; + +const addPanel = (options: AddPanelOptions, api: DockviewApi) => { + const group = api.groups.find((group) => group.locked !== NO_DROP_TARGET); + + const panel = api.addPanel({ + ...options, + position: { + direction: group?.id ? "within" : "right", + referenceGroup: group?.id, + }, + minimumWidth: 300, + initialWidth: group?.width || 400, + floating: false, + }); + + return panel; +}; + +const equals = (a: Set, b: Set) => { + if (a.size !== b.size) { + return false; + } + + return a.isSubsetOf(b) && b.isSubsetOf(a); +}; + +function Docked( + { onReady, components, mainView, ...props }: Props, +) { + const [api, setApi] = useState(null); + const [openPanels, setOpenPanels] = useState(new Set()); + const mainViewName = mainView.displayName || "Main View"; + + const wrappedComponents = useMemo( + () => { + const record = Object.fromEntries( + Object.entries(components).map(([key, value]) => [ + key, + adapter(value.Component), + ]), + ); + + record[mainViewName] = adapter(mainView); + + return record; + }, + [components, mainView, mainViewName], + ); + + const availablePanels = useMemo(() => { + return new Set(Object.keys(components)); + }, [components]); + + const handleReady = useCallback((event: DockviewReadyEvent) => { + setApi(event.api); + + const mainPanel = event.api.addPanel({ + id: mainViewName, + component: mainViewName, + title: mainViewName, + }); + + mainPanel.group.locked = NO_DROP_TARGET; + + const initialPanels = new Set(); + Object.entries(components).forEach(([key, value]) => { + if (value.initialOpen) { + initialPanels.add(key); + + addPanel({ + id: key, + component: key, + title: value.Component.displayName || key, + }, event.api); + } + }); + + setOpenPanels(initialPanels); + + event.api.onDidLayoutChange(() => { + const currentPanels = new Set(event.api.panels.map((panel) => panel.id)); + + setOpenPanels((prev) => + equals(prev, currentPanels) ? prev : currentPanels + ); + }); + + onReady?.(event); + }, [onReady, mainViewName, components]); + + useEffect(() => { + const handleMessage = ( + event: CustomEvent>, + ) => { + const { detail } = event; + + if (!api) { + return; + } + + const panel = api.getPanel(detail.id); + + if (panel) { + panel.api.close(); + } else { + addPanel(detail, api); + } + }; + + // @ts-expect-error - I don't really know how to properly type this + channel.addEventListener("message", handleMessage); + + return () => { + // @ts-expect-error - I don't really know how to properly type this + channel.removeEventListener("message", handleMessage); + }; + }, [api, channel]); + + return ( + + + + ); +} + +export default Docked; diff --git a/apps/web/src/components/integrations/detail/context.ts b/apps/web/src/components/integrations/detail/context.ts new file mode 100644 index 0000000000..181766fe13 --- /dev/null +++ b/apps/web/src/components/integrations/detail/context.ts @@ -0,0 +1,16 @@ +import { type Integration } from "@deco/sdk"; +import { createContext, useContext as useContextReact } from "react"; +import { UseFormReturn } from "react-hook-form"; + +export interface IContext { + form: UseFormReturn; + integration: Integration; +} + +export const Context = createContext(null); + +export const useFormContext = () => { + const context = useContextReact(Context); + + return context!; +}; diff --git a/apps/web/src/components/integrations/detail/edit.tsx b/apps/web/src/components/integrations/detail/edit.tsx index fc32620fc8..0ace98b6ca 100644 --- a/apps/web/src/components/integrations/detail/edit.tsx +++ b/apps/web/src/components/integrations/detail/edit.tsx @@ -1,29 +1,74 @@ -import { useIntegration } from "@deco/sdk"; -import { Spinner } from "@deco/ui/components/spinner.tsx"; -import { useParams } from "react-router"; +import { type Integration, IntegrationSchema, useIntegration } from "@deco/sdk"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Icon } from "@deco/ui/components/icon.tsx"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { Link, useParams } from "react-router"; +import { DockedPageLayout, DockedToggleButton } from "../../pageLayout.tsx"; +import { Context } from "./context.ts"; import { DetailForm } from "./form.tsx"; +import { Inspector } from "./inspector.tsx"; -function EditIntegration({ id }: { id: string }) { - const { isLoading, data: integration } = useIntegration(id); +const MAIN = { + header: Header, + main: DetailForm, +}; - if (isLoading) { - return ( -
- +const TABS = { + inspector: { + Component: Inspector, + initialOpen: true, + }, +}; + +function Header() { + return ( + <> +
+
- ); - } - return ; +
+ + + +
+ + ); } export default function Edit() { const { id } = useParams(); + const { data: integration } = useIntegration(id!); - // TODO: add nice error handling - if (!id) { - return
No id
; - } + const form = useForm({ + resolver: zodResolver(IntegrationSchema), + defaultValues: { + id: integration.id || crypto.randomUUID(), + name: integration.name || "", + description: integration.description || "", + icon: integration.icon || "", + connection: integration.connection || { + type: "HTTP" as const, + url: "https://example.com/sse", + token: "", + }, + }, + }); - return ; + return ( + + + + ); } diff --git a/apps/web/src/components/integrations/detail/form.tsx b/apps/web/src/components/integrations/detail/form.tsx index a91056b8c3..d01f87f52a 100644 --- a/apps/web/src/components/integrations/detail/form.tsx +++ b/apps/web/src/components/integrations/detail/form.tsx @@ -1,8 +1,6 @@ import { type Integration, - IntegrationSchema, type MCPConnection, - useCreateIntegration, useUpdateIntegration, } from "@deco/sdk"; import { Button } from "@deco/ui/components/button.tsx"; @@ -25,50 +23,28 @@ import { } from "@deco/ui/components/select.tsx"; import { Spinner } from "@deco/ui/components/spinner.tsx"; import { Textarea } from "@deco/ui/components/textarea.tsx"; -import { zodResolver } from "@hookform/resolvers/zod"; import { useRef } from "react"; -import { useForm } from "react-hook-form"; -import { Link, useNavigate } from "react-router"; -import { useBasePath } from "../../../hooks/useBasePath.ts"; -import { PageLayout } from "../../pageLayout.tsx"; -import Inspector from "./inspector.tsx"; +import { useNavigate } from "react-router"; import { trackEvent } from "../../../hooks/analytics.ts"; +import { useBasePath } from "../../../hooks/useBasePath.ts"; +import { useFormContext } from "./context.ts"; -interface DetailProps { - integration?: Integration; -} - -export function DetailForm({ integration: editIntegration }: DetailProps) { +export function DetailForm() { + const { integration: editIntegration, form } = useFormContext(); const fileInputRef = useRef(null); const navigate = useNavigate(); const withBasePath = useBasePath(); - const integrationId = editIntegration?.id; - - const createIntegration = useCreateIntegration(); const updateIntegration = useUpdateIntegration(); - const form = useForm({ - resolver: zodResolver(IntegrationSchema), - defaultValues: { - id: integrationId ?? crypto.randomUUID(), - name: editIntegration?.name ?? "", - description: editIntegration?.description ?? "", - icon: editIntegration?.icon ?? "", - connection: editIntegration?.connection ?? { - type: "HTTP" as const, - url: "https://example.com/sse", - token: "", - }, - }, - }); + const isMutating = updateIntegration.isPending; const iconValue = form.watch("icon"); const connection = form.watch("connection"); // Handle connection type change const handleConnectionTypeChange = (value: MCPConnection["type"]) => { - const ec = editIntegration?.connection; + const ec = editIntegration.connection; form.setValue( "connection", @@ -118,27 +94,16 @@ export function DetailForm({ integration: editIntegration }: DetailProps) { const onSubmit = async (data: Integration) => { try { - if (editIntegration) { - // Update the existing integration - await updateIntegration.mutateAsync(data); - - trackEvent("integration_update", { - success: true, - data, - }); - } else { - // Create a new integration - await createIntegration.mutateAsync(data); - navigate(withBasePath("/integrations")); + // Update the existing integration + await updateIntegration.mutateAsync(data); - trackEvent("integration_create", { - success: true, - data, - }); - } + trackEvent("integration_update", { + success: true, + data, + }); } catch (error) { console.error( - `Error ${editIntegration ? "updating" : "creating"} integration:`, + `Error updating integration:`, error, ); @@ -150,279 +115,255 @@ export function DetailForm({ integration: editIntegration }: DetailProps) { } }; - const isMutating = createIntegration.isPending || updateIntegration.isPending; - return ( - - - - Back - - - } - > -
- {editIntegration ? editIntegration.name : "Create New Integration"} -
- -
- -
-
- Icon - ( - -
- - -
- {iconValue && /^(data:)|(http?s:)/.test(iconValue) - ? ( - <> - Icon preview -
- -
- - ) - : ( - <> + + +
+
+ Icon + ( + +
+ + +
+ {iconValue && /^(data:)|(http?s:)/.test(iconValue) + ? ( + <> + Icon preview +
- - Upload Icon - - - )} - -
- -
- - - )} - /> -
-
- ( - - Name - - - - - - )} - /> - ( - - Description - -