diff --git a/plane-src/apps/web/core/components/ai-workspace/product-settings-modal.tsx b/plane-src/apps/web/core/components/ai-workspace/product-settings-modal.tsx index 669cc36..224e55a 100644 --- a/plane-src/apps/web/core/components/ai-workspace/product-settings-modal.tsx +++ b/plane-src/apps/web/core/components/ai-workspace/product-settings-modal.tsx @@ -4,8 +4,9 @@ * See the LICENSE file for details. */ -import { useEffect, useMemo, useState } from "react"; -import type { FormEvent, MouseEvent } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { FormEvent, MouseEvent as ReactMouseEvent } from "react"; +import { createPortal } from "react-dom"; // plane imports import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; // services @@ -17,6 +18,10 @@ import type { } from "@/services/workspace-ai-workspace.service"; const DEFAULT_WINDOWS_AGENT_PORT = "8787"; +const connectionModeSelectOptions: Array<{ value: TAIWorkspaceExecutorConnectionMode; label: string }> = [ + { value: "hub", label: "Cloud Hub" }, + { value: "direct", label: "Прямое подключение" }, +]; const capabilityOptions = [ { value: "chat", label: "Chat" }, { value: "code", label: "Code" }, @@ -62,7 +67,7 @@ function emptyDraft(): TExecutorDraft { endpoint: "", workspacePath: "", agentPort: DEFAULT_WINDOWS_AGENT_PORT, - accountLabel: "", + accountLabel: "OpenAI account", capabilities: ["ops"], }; } @@ -108,6 +113,151 @@ function errorText(error: unknown) { return String((error as any)?.message || (error as any)?.error || error); } +function AIWorkspaceGlassSelect({ + value, + options, + ariaLabel, + disabled = false, + onChange, +}: { + value: T; + options: Array<{ value: T; label: string; disabled?: boolean }>; + ariaLabel: string; + disabled?: boolean; + onChange: (value: T) => void; +}) { + const [open, setOpen] = useState(false); + const [menuRect, setMenuRect] = useState({ top: 0, left: 0, width: 0 }); + const rootRef = useRef(null); + const menuRef = useRef(null); + const selectId = useMemo(() => `ops-ai-select-${Math.random().toString(16).slice(2)}`, []); + const selected = options.find((option) => option.value === value) ?? options[0]; + + const updateMenuRect = () => { + const root = rootRef.current; + if (!root) return; + const rect = root.getBoundingClientRect(); + const valueRect = root.querySelector(".nodedc-select__value")?.getBoundingClientRect(); + const menuMaxHeight = 232; + const bottomTop = rect.bottom + 6; + const top = + bottomTop + menuMaxHeight > window.innerHeight - 12 ? Math.max(12, rect.top - menuMaxHeight - 6) : bottomTop; + setMenuRect({ + top, + left: valueRect?.left ?? rect.left, + width: Math.max(180, valueRect?.width ?? rect.width), + }); + }; + + useEffect(() => { + if (!open) return; + updateMenuRect(); + + const handlePointerDown = (event: MouseEvent) => { + const root = rootRef.current; + const menu = menuRef.current; + if ( + event.target instanceof Node && + root && + !root.contains(event.target) && + (!menu || !menu.contains(event.target)) + ) { + setOpen(false); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + const handleOtherSelectOpen = (event: Event) => { + const detail = (event as CustomEvent<{ id?: string }>).detail; + if (detail?.id !== selectId) setOpen(false); + }; + const handleViewportChange = () => updateMenuRect(); + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + window.addEventListener("nodedc-select-open", handleOtherSelectOpen as EventListener); + window.addEventListener("resize", handleViewportChange); + window.addEventListener("scroll", handleViewportChange, true); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("nodedc-select-open", handleOtherSelectOpen as EventListener); + window.removeEventListener("resize", handleViewportChange); + window.removeEventListener("scroll", handleViewportChange, true); + }; + }, [open, selectId]); + + const toggleOpen = () => { + if (disabled) return; + setOpen((current) => { + if (!current) { + updateMenuRect(); + window.dispatchEvent(new CustomEvent("nodedc-select-open", { detail: { id: selectId } })); + } + return !current; + }); + }; + + return ( +
+
+
{selected?.label}
+ +
+ + {open && typeof document !== "undefined" + ? createPortal( +
+ {options.map((option) => ( + + ))} +
, + document.body + ) + : null} +
+ ); +} + export function AIWorkspaceProductSettingsModal({ isOpen, executors, @@ -178,7 +328,11 @@ export function AIWorkspaceProductSettingsModal({ if (detailsMode === "new") { const payload = await onCreate(input); if (!payload) return; - const next = payload?.executors.find((executor) => executor.name === input.name) ?? payload?.executors[0] ?? null; + const next = + payload?.executors.find((executor) => executor.id === payload.selectedExecutorId) ?? + payload?.executors.find((executor) => executor.name === input.name) ?? + payload?.executors[0] ?? + null; setEditingId(next?.id || ""); setDetailsMode(next ? "edit" : "context"); return; @@ -203,7 +357,7 @@ export function AIWorkspaceProductSettingsModal({ }); }; - const downloadAgent = async (event: MouseEvent) => { + const downloadAgent = async (event: ReactMouseEvent) => { if (!editingExecutor) return; event.preventDefault(); await onDownloadAgent(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT); @@ -214,8 +368,8 @@ export function AIWorkspaceProductSettingsModal({ isOpen={isOpen} handleClose={onClose} position={EModalPosition.CENTER} - width={EModalWidth.XXL} - className="overflow-visible" + width={EModalWidth.VIIXL} + className="ai-workspace-settings-portal overflow-visible" >
@@ -388,19 +542,12 @@ export function AIWorkspaceProductSettingsModal({ {draft.connectionMode === "hub" && detailsMode === "edit" && editingExecutor ? ( @@ -441,26 +588,6 @@ export function AIWorkspaceProductSettingsModal({ ) : null} - - - -