fix: align ops ai workspace settings ui

This commit is contained in:
DCCONSTRUCTIONS 2026-06-09 14:49:50 +03:00
parent b1d19cf5df
commit fcd014e26d
2 changed files with 337 additions and 39 deletions

View File

@ -4,8 +4,9 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent, MouseEvent } from "react"; import type { FormEvent, MouseEvent as ReactMouseEvent } from "react";
import { createPortal } from "react-dom";
// plane imports // plane imports
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// services // services
@ -17,6 +18,10 @@ import type {
} from "@/services/workspace-ai-workspace.service"; } from "@/services/workspace-ai-workspace.service";
const DEFAULT_WINDOWS_AGENT_PORT = "8787"; const DEFAULT_WINDOWS_AGENT_PORT = "8787";
const connectionModeSelectOptions: Array<{ value: TAIWorkspaceExecutorConnectionMode; label: string }> = [
{ value: "hub", label: "Cloud Hub" },
{ value: "direct", label: "Прямое подключение" },
];
const capabilityOptions = [ const capabilityOptions = [
{ value: "chat", label: "Chat" }, { value: "chat", label: "Chat" },
{ value: "code", label: "Code" }, { value: "code", label: "Code" },
@ -62,7 +67,7 @@ function emptyDraft(): TExecutorDraft {
endpoint: "", endpoint: "",
workspacePath: "", workspacePath: "",
agentPort: DEFAULT_WINDOWS_AGENT_PORT, agentPort: DEFAULT_WINDOWS_AGENT_PORT,
accountLabel: "", accountLabel: "OpenAI account",
capabilities: ["ops"], capabilities: ["ops"],
}; };
} }
@ -108,6 +113,151 @@ function errorText(error: unknown) {
return String((error as any)?.message || (error as any)?.error || error); return String((error as any)?.message || (error as any)?.error || error);
} }
function AIWorkspaceGlassSelect<T extends string>({
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<HTMLDivElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(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<HTMLElement>(".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 (
<div ref={rootRef} className="nodedc-select">
<div className="nodedc-select__control">
<div className="nodedc-select__value">{selected?.label}</div>
<button
type="button"
className="nodedc-select__toggle"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={toggleOpen}
>
<span className="nodedc-select__chevron" aria-hidden="true" />
</button>
</div>
{open && typeof document !== "undefined"
? createPortal(
<div
ref={menuRef}
className="nodedc-dropdown-surface nodedc-select__menu nodedc-ai-workspace-settings-select-menu"
role="listbox"
style={{
position: "fixed",
top: menuRect.top,
left: menuRect.left,
width: menuRect.width,
}}
>
{options.map((option) => (
<button
key={option.value}
type="button"
className="nodedc-dropdown-option nodedc-select__option"
data-selected={option.value === value ? "true" : undefined}
data-value={option.value}
role="option"
aria-selected={option.value === value}
aria-disabled={option.disabled === true}
disabled={option.disabled === true}
onClick={() => {
if (option.disabled) return;
onChange(option.value);
setOpen(false);
}}
>
{option.label}
</button>
))}
</div>,
document.body
)
: null}
</div>
);
}
export function AIWorkspaceProductSettingsModal({ export function AIWorkspaceProductSettingsModal({
isOpen, isOpen,
executors, executors,
@ -178,7 +328,11 @@ export function AIWorkspaceProductSettingsModal({
if (detailsMode === "new") { if (detailsMode === "new") {
const payload = await onCreate(input); const payload = await onCreate(input);
if (!payload) return; 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 || ""); setEditingId(next?.id || "");
setDetailsMode(next ? "edit" : "context"); setDetailsMode(next ? "edit" : "context");
return; return;
@ -203,7 +357,7 @@ export function AIWorkspaceProductSettingsModal({
}); });
}; };
const downloadAgent = async (event: MouseEvent<HTMLAnchorElement>) => { const downloadAgent = async (event: ReactMouseEvent<HTMLAnchorElement>) => {
if (!editingExecutor) return; if (!editingExecutor) return;
event.preventDefault(); event.preventDefault();
await onDownloadAgent(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT); await onDownloadAgent(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
@ -214,8 +368,8 @@ export function AIWorkspaceProductSettingsModal({
isOpen={isOpen} isOpen={isOpen}
handleClose={onClose} handleClose={onClose}
position={EModalPosition.CENTER} position={EModalPosition.CENTER}
width={EModalWidth.XXL} width={EModalWidth.VIIXL}
className="overflow-visible" className="ai-workspace-settings-portal overflow-visible"
> >
<div className="nodedc-glass-modal ai-settings-modal ai-settings-modal--workspace nodedc-ai-workspace-product-modal"> <div className="nodedc-glass-modal ai-settings-modal ai-settings-modal--workspace nodedc-ai-workspace-product-modal">
<div className="nodedc-glass-modal__head"> <div className="nodedc-glass-modal__head">
@ -388,19 +542,12 @@ export function AIWorkspaceProductSettingsModal({
<label className="ai-settings-field"> <label className="ai-settings-field">
<span>Подключение</span> <span>Подключение</span>
<select <AIWorkspaceGlassSelect
className="ai-settings-input"
value={draft.connectionMode} value={draft.connectionMode}
onChange={(event) => options={connectionModeSelectOptions}
setDraft((current) => ({ ariaLabel="Тип подключения"
...current, onChange={(value) => setDraft((current) => ({ ...current, connectionMode: value }))}
connectionMode: event.target.value as TAIWorkspaceExecutorConnectionMode, />
}))
}
>
<option value="hub">Cloud Hub</option>
<option value="direct">Прямое подключение</option>
</select>
</label> </label>
{draft.connectionMode === "hub" && detailsMode === "edit" && editingExecutor ? ( {draft.connectionMode === "hub" && detailsMode === "edit" && editingExecutor ? (
@ -441,26 +588,6 @@ export function AIWorkspaceProductSettingsModal({
</label> </label>
) : null} ) : null}
<label className="ai-settings-field">
<span>Workspace path</span>
<input
className="ai-settings-input"
value={draft.workspacePath}
placeholder="C:\\Projects\\NODEDC"
onChange={(event) => setDraft((current) => ({ ...current, workspacePath: event.target.value }))}
/>
</label>
<label className="ai-settings-field">
<span>Порт агента</span>
<input
className="ai-settings-input"
value={draft.agentPort}
inputMode="numeric"
onChange={(event) => setDraft((current) => ({ ...current, agentPort: event.target.value }))}
/>
</label>
<label className="ai-settings-field"> <label className="ai-settings-field">
<span>Аккаунт</span> <span>Аккаунт</span>
<input <input

View File

@ -676,6 +676,27 @@
backdrop-filter: blur(42px); backdrop-filter: blur(42px);
} }
body:has(.nodedc-ai-workspace-product-modal) [data-headlessui-portal] [class~="z-[180]"] {
z-index: 27080 !important;
}
.ai-workspace-settings-portal {
display: flex;
width: min(1280px, calc(100vw - 32px)) !important;
max-width: min(1280px, calc(100vw - 32px)) !important;
justify-content: center;
overflow: visible;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
.ai-workspace-settings-portal > .nodedc-ai-workspace-product-modal {
margin-inline: auto;
}
.nodedc-ai-workspace-product-modal:has(.ai-settings-workspace[data-details-open="true"]) { .nodedc-ai-workspace-product-modal:has(.ai-settings-workspace[data-details-open="true"]) {
width: min(1220px, calc(100vw - 32px)); width: min(1220px, calc(100vw - 32px));
} }
@ -1163,6 +1184,128 @@
color: #ffffff; color: #ffffff;
} }
.nodedc-select {
position: relative;
min-width: 0;
--nodedc-select-toggle-w: var(--wf-share-control-h);
--nodedc-select-gap: 8px;
}
.ai-settings-field .nodedc-select {
width: 100%;
--wf-share-control-h: 46px;
}
.nodedc-select__control {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--nodedc-select-toggle-w);
gap: var(--nodedc-select-gap);
}
.nodedc-select__value {
display: flex;
height: var(--wf-share-control-h);
min-width: 0;
align-items: center;
overflow: hidden;
border: 0;
border-radius: 14px;
background: rgba(255, 255, 255, 0.045);
color: rgba(255, 255, 255, 0.92);
padding: 0 15px;
font-size: 13px;
font-weight: 680;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: var(--nodedc-glass-control-shadow);
}
.nodedc-select__toggle {
display: inline-grid;
width: 100%;
height: var(--wf-share-control-h);
place-items: center;
border: 0;
border-radius: 14px;
background: var(--nodedc-glass-control-bg, rgba(255, 255, 255, 0.045));
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
outline: none !important;
padding: 0;
box-shadow: var(--nodedc-glass-control-shadow) !important;
}
.nodedc-select__toggle:hover,
.nodedc-select__toggle[aria-expanded="true"] {
background: var(--nodedc-glass-control-hover);
}
.nodedc-select__toggle:disabled {
cursor: default;
opacity: 0.48;
}
.nodedc-select__chevron {
width: 8px;
height: 8px;
border-right: 1.6px solid currentColor;
border-bottom: 1.6px solid currentColor;
opacity: 0.82;
transform: translateY(-2px) rotate(45deg);
}
.nodedc-dropdown-surface.nodedc-ai-workspace-settings-select-menu {
--brand: rgb(var(--nodedc-accent-rgb));
--brand-rgb: var(--nodedc-accent-rgb);
--nodedc-glass-control-hover: rgba(255, 255, 255, 0.075);
--nodedc-glass-panel-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 28px 78px rgba(0, 0, 0, 0.48);
z-index: 27120;
display: grid;
gap: 2px;
min-width: 0;
max-height: 232px;
overflow: auto;
border: 0;
border-radius: 20px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.015) 100%), rgba(18, 18, 22, 0.94);
padding: 7px;
box-shadow:
0 18px 60px rgba(0, 0, 0, 0.48),
var(--nodedc-glass-panel-shadow);
-webkit-backdrop-filter: blur(44px) saturate(145%) brightness(1.05);
backdrop-filter: blur(44px) saturate(145%) brightness(1.05);
}
.nodedc-ai-workspace-settings-select-menu::-webkit-scrollbar {
width: 0;
height: 0;
}
.nodedc-select__option {
min-height: 42px;
border: 0;
border-radius: 14px;
background: transparent;
color: rgba(255, 255, 255, 0.72);
outline: none !important;
padding: 0 12px;
text-align: left;
box-shadow: none !important;
}
.nodedc-select__option:hover,
.nodedc-select__option:focus-visible,
.nodedc-select__option[data-selected="true"] {
background: var(--nodedc-glass-control-hover);
color: rgba(255, 255, 255, 0.96);
}
.nodedc-select__option[data-selected="true"] {
background: rgba(var(--brand-rgb, 51, 163, 255), 0.18);
}
.ai-settings-capabilities { .ai-settings-capabilities {
display: flex; display: flex;
min-width: 0; min-width: 0;
@ -1470,6 +1613,34 @@
line-height: 1; line-height: 1;
} }
.nodedc-ai-workspace-console .nodedc-glass-modal__close-mark {
position: relative;
display: block;
width: 12px;
height: 12px;
}
.nodedc-ai-workspace-console .nodedc-glass-modal__close-mark::before,
.nodedc-ai-workspace-console .nodedc-glass-modal__close-mark::after {
position: absolute;
top: 50%;
left: 50%;
width: 12.5px;
height: 1.2px;
border-radius: 999px;
background: currentColor;
content: "";
transform-origin: center;
}
.nodedc-ai-workspace-console .nodedc-glass-modal__close-mark::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.nodedc-ai-workspace-console .nodedc-glass-modal__close-mark::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
.nodedc-ai-workspace-console .ai-console-threads, .nodedc-ai-workspace-console .ai-console-threads,
.nodedc-ai-workspace-console .ai-console-main { .nodedc-ai-workspace-console .ai-console-main {
min-width: 0; min-width: 0;