fix: align ops ai workspace settings UI

This commit is contained in:
DCCONSTRUCTIONS
2026-06-09 13:51:48 +03:00
parent 71979526cf
commit 097012ae31
2 changed files with 938 additions and 475 deletions
@@ -8,11 +8,10 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import type { FormEvent, MouseEvent } from "react";
import { createPortal } from "react-dom";
import useSWR from "swr";
import { CheckCircle2, Download, MessageCircle, Monitor, Plus, RefreshCw, Settings, Trash2, X } from "lucide-react";
import { MessageCircle } from "lucide-react";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
import { cn } from "@plane/utils";
// services
import { WorkspaceAIWorkspaceService } from "@/services/workspace-ai-workspace.service";
import type {
@@ -24,14 +23,14 @@ import type {
const workspaceAIWorkspaceService = new WorkspaceAIWorkspaceService();
const DEFAULT_WINDOWS_AGENT_PORT = "8787";
const capabilityOptions = [
{ value: "chat", label: "Chat" },
{ value: "code", label: "Code" },
{ value: "engine", label: "Engine" },
{ value: "ops", label: "Ops" },
{ value: "ndc-agent-core", label: "NDC Agent" },
{ value: "mcp", label: "MCP" },
{ value: "workflows", label: "Workflows" },
];
const aiWorkspaceCloseButtonClassName =
"absolute top-0.5 right-0.5 flex h-12 w-12 items-center justify-center rounded-full border-0 bg-[#17181B] text-white shadow-none ring-0 transition-transform outline-none hover:scale-[1.03] hover:bg-[#0F1012]";
type TAIWorkspaceGlobalControlProps = {
workspaceSlug: string;
};
@@ -67,7 +66,8 @@ function draftFromExecutor(executor: TAIWorkspaceExecutor | null | undefined): T
workspacePath: executor.workspacePath || "",
agentPort: String(executor.agentPort || DEFAULT_WINDOWS_AGENT_PORT),
accountLabel: executor.accountLabel || "",
capabilities: Array.isArray(executor.capabilities) && executor.capabilities.length ? executor.capabilities : ["ops"],
capabilities:
Array.isArray(executor.capabilities) && executor.capabilities.length ? executor.capabilities : ["ops"],
};
}
@@ -86,11 +86,11 @@ function toInput(draft: TExecutorDraft): TAIWorkspaceExecutorInput {
}
function statusLabel(status: string | null | undefined) {
if (status === "online") return "online";
if (status === "offline") return "offline";
if (status === "checking") return "checking";
if (status === "error") return "error";
return "unknown";
if (status === "online") return "Online";
if (status === "offline") return "Offline";
if (status === "checking") return "Checking";
if (status === "error") return "Error";
return "Unknown";
}
export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalControlProps) {
@@ -157,22 +157,21 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo
setActionError("");
}, []);
const runMutation = useCallback(
async (id: string, action: () => Promise<unknown>, successText: string) => {
setBusyId(id);
setActionError("");
setNotice("");
try {
await action();
setNotice(successText);
} catch (mutationError: any) {
setActionError(String(mutationError?.message || mutationError?.error || mutationError || "AI Workspace request failed"));
} finally {
setBusyId("");
}
},
[]
);
const runMutation = useCallback(async (id: string, action: () => Promise<unknown>, successText: string) => {
setBusyId(id);
setActionError("");
setNotice("");
try {
await action();
setNotice(successText);
} catch (mutationError: any) {
setActionError(
String(mutationError?.message || mutationError?.error || mutationError || "AI Workspace request failed")
);
} finally {
setBusyId("");
}
}, []);
const startNew = () => {
setDetailsMode("new");
@@ -215,30 +214,43 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo
return;
}
if (detailsMode === "new") {
await runMutation("save", async () => {
const payload = await workspaceAIWorkspaceService.createExecutor(workspaceSlug, input);
await mutate(payload, { revalidate: false });
const next = payload.executors.find((executor) => executor.name === input.name) ?? payload.executors[0] ?? null;
setEditingId(next?.id || "");
setDetailsMode(next ? "edit" : "context");
}, "Устройство создано");
await runMutation(
"save",
async () => {
const payload = await workspaceAIWorkspaceService.createExecutor(workspaceSlug, input);
await mutate(payload, { revalidate: false });
const next =
payload.executors.find((executor) => executor.name === input.name) ?? payload.executors[0] ?? null;
setEditingId(next?.id || "");
setDetailsMode(next ? "edit" : "context");
},
"Устройство создано"
);
return;
}
if (!editingExecutor) return;
await runMutation("save", async () => {
const payload = await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, editingExecutor.id, input);
await mutate(payload, { revalidate: false });
setDetailsMode("context");
}, "Устройство сохранено");
await runMutation(
"save",
async () => {
const payload = await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, editingExecutor.id, input);
await mutate(payload, { revalidate: false });
setDetailsMode("context");
},
"Устройство сохранено"
);
};
const deleteEditingExecutor = async () => {
if (!editingExecutor) return;
await runMutation(`delete:${editingExecutor.id}`, async () => {
const payload = await workspaceAIWorkspaceService.deleteExecutor(workspaceSlug, editingExecutor.id);
await mutate(payload, { revalidate: false });
setDetailsMode("context");
}, "Устройство удалено");
await runMutation(
`delete:${editingExecutor.id}`,
async () => {
const payload = await workspaceAIWorkspaceService.deleteExecutor(workspaceSlug, editingExecutor.id);
await mutate(payload, { revalidate: false });
setDetailsMode("context");
},
"Устройство удалено"
);
};
const toggleCapability = (value: string) => {
@@ -253,13 +265,25 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo
const downloadAgent = async (event: MouseEvent<HTMLAnchorElement>) => {
if (!editingExecutor) return;
event.preventDefault();
await runMutation("download", async () => {
const payload = await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, editingExecutor.id, toInput(draft));
await mutate(payload, { revalidate: false });
window.location.href = workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, editingExecutor.id, {
port: draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT,
});
}, "Installer подготовлен");
await runMutation(
"download",
async () => {
const payload = await workspaceAIWorkspaceService.updateExecutor(
workspaceSlug,
editingExecutor.id,
toInput(draft)
);
await mutate(payload, { revalidate: false });
window.location.href = workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(
workspaceSlug,
editingExecutor.id,
{
port: draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT,
}
);
},
"Installer подготовлен"
);
};
return (
@@ -287,214 +311,301 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo
width={EModalWidth.XXL}
className="overflow-visible"
>
<div className="nodedc-ai-workspace-modal relative">
<div className="nodedc-ai-workspace-modal__head">
<div>
<div className="nodedc-ai-workspace-kicker">AI Space</div>
<h3>AI Workspace</h3>
<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__titles">
<div className="nodedc-glass-modal__title">AI Workspace Settings</div>
<div className="nodedc-glass-modal__accent-title" title="MODEL CONTEXT">
MODEL CONTEXT
</div>
</div>
<button type="button" className={aiWorkspaceCloseButtonClassName} onClick={handleClose} aria-label="Закрыть AI Workspace">
<X className="size-5" />
<button type="button" className="nodedc-glass-modal__close" onClick={handleClose} aria-label="Закрыть">
<span className="nodedc-glass-modal__close-mark" aria-hidden="true" />
</button>
</div>
<div className="nodedc-ai-settings-workspace" data-details-open={detailsOpen ? "true" : undefined}>
<aside className="nodedc-ai-settings-device-list">
<div className="nodedc-ai-settings-devices-head">
<div>
<div className="nodedc-ai-workspace-kicker">Devices</div>
<div className="nodedc-ai-settings-devices-title">Устройства</div>
</div>
<button type="button" className="nodedc-ai-icon-button" aria-label="Подключить устройство" onClick={startNew}>
<Plus className="size-4" />
</button>
</div>
{isLoading ? <div className="nodedc-ai-settings-empty-list">Загрузка</div> : null}
{executors.map((executor) => (
<button
key={executor.id}
type="button"
className="nodedc-ai-settings-executor-row"
data-active={activeExecutor?.id === executor.id && detailsMode !== "new" ? "true" : undefined}
onClick={() => selectExecutor(executor)}
>
<span>
<Monitor className="size-4" />
<b>{executor.name}</b>
</span>
<small>
{executor.connectionMode === "hub" ? executor.pairingCode || "Cloud Hub" : "Direct"} · {statusLabel(executor.status)}
</small>
</button>
))}
{!isLoading && !executors.length ? (
<div className="nodedc-ai-settings-empty-list">
<span>{error ? "Registry недоступен" : "Устройств нет"}</span>
<small>{error ? String((error as any)?.message || (error as any)?.error || error) : "Подключенные устройства появятся здесь."}</small>
</div>
) : null}
</aside>
<section className="nodedc-ai-settings-context-pane">
<div className="nodedc-ai-settings-form-top">
<div>
<div className="nodedc-ai-settings-section-title">{detailsMode === "new" ? "Новое устройство" : "Рабочий контекст"}</div>
<div className="nodedc-ai-settings-subtitle">Общий пользовательский ассистент для Ops и Engine</div>
</div>
{activeExecutor ? <span className="nodedc-ai-status-pill">{statusLabel(activeExecutor.status)}</span> : null}
</div>
{activeExecutor && detailsMode !== "new" ? (
<>
<div className="nodedc-ai-settings-active-model">
<span>Активное устройство</span>
<b>{activeExecutor.name}</b>
<small>{activeExecutor.connectionMode === "hub" ? activeExecutor.pairingCode || "Cloud Hub" : activeExecutor.endpoint}</small>
</div>
<div className="nodedc-ai-settings-note" data-ready="true">
Это устройство сохранено в общем AI Workspace registry. Оно будет видно в Engine и Ops для этого же пользователя.
</div>
<div className="nodedc-ai-settings-actions">
<button type="button" className="modal-btn" disabled={isBusy} onClick={() => void mutate()}>
<RefreshCw className={cn("size-4", isLoading && "animate-spin")} />
Обновить
</button>
<button type="button" className="modal-btn" disabled={isBusy} onClick={editActive}>
<Settings className="size-4" />
Настройки
</button>
<button type="button" className="modal-btn btn-primary" onClick={handleClose}>
<CheckCircle2 className="size-4" />
Готово
</button>
</div>
</>
) : detailsMode === "new" ? (
<div className="nodedc-ai-settings-empty-context">
<span>Новое устройство</span>
<small>Заполни параметры справа и нажми «Подключить». После сохранения появятся pairing code и installer агента.</small>
</div>
) : error ? (
<div className="nodedc-ai-settings-empty-context" data-tone="error">
<span>Registry backend недоступен</span>
<small>{String((error as any)?.message || (error as any)?.error || error)}</small>
<button type="button" className="modal-btn btn-primary" onClick={() => void mutate()}>
Повторить
</button>
</div>
) : (
<div className="nodedc-ai-settings-empty-context">
<span>Устройство не выбрано</span>
<small>Подключи Codex worker. Если устройство уже добавлено в Engine, оно появится здесь автоматически.</small>
<button type="button" className="modal-btn btn-primary" onClick={startNew}>
Подключить устройство
</button>
</div>
)}
{notice ? <div className="nodedc-ai-settings-note" data-tone="success">{notice}</div> : null}
{actionError ? <div className="nodedc-ai-settings-error">{actionError}</div> : null}
</section>
{detailsOpen ? (
<form className="nodedc-ai-settings-details-pane" onSubmit={saveDraft}>
<div className="nodedc-ai-settings-form-top">
<div className="nodedc-glass-modal__body">
<div className="ai-settings-workspace" data-details-open={detailsOpen ? "true" : undefined}>
<aside className="ai-settings-model-list">
<div className="ai-settings-devices-head">
<div>
<div className="nodedc-ai-settings-section-title">{detailsMode === "new" ? "Подключить устройство" : "Параметры устройства"}</div>
<div className="nodedc-ai-settings-subtitle">Cloud Hub или прямой endpoint</div>
<div className="ai-console-kicker">AI Space</div>
<div className="ai-settings-devices-title">Устройства</div>
</div>
{detailsMode === "edit" ? (
<button type="button" className="nodedc-ai-mini-close" aria-label="Закрыть параметры" onClick={() => setDetailsMode("context")}>
<X className="size-4" />
</button>
) : null}
</div>
<label className="nodedc-ai-settings-field">
<span>Имя устройства</span>
<input value={draft.name} placeholder="MACPRO Codex" onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} />
</label>
<label className="nodedc-ai-settings-field">
<span>Подключение</span>
<select value={draft.connectionMode} onChange={(event) => setDraft((current) => ({ ...current, connectionMode: event.target.value as TAIWorkspaceExecutorConnectionMode }))}>
<option value="hub">Cloud Hub</option>
<option value="direct">Прямое подключение</option>
</select>
</label>
{draft.connectionMode === "hub" && detailsMode === "edit" && editingExecutor ? (
<div className="nodedc-ai-settings-download-row">
<label className="nodedc-ai-settings-field">
<span>Pairing code</span>
<input value={editingExecutor.pairingCode || ""} readOnly placeholder="XXXX-XXXX-XXXX" />
</label>
<a
className="modal-btn btn-primary nodedc-ai-settings-download"
href={workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, editingExecutor.id)}
onClick={downloadAgent}
download
>
<Download className="size-4" />
Скачать агент
</a>
</div>
) : draft.connectionMode === "hub" ? (
<div className="nodedc-ai-settings-note">
После сохранения общий AI Workspace создаст pairing code и installer агента.
</div>
) : null}
{draft.connectionMode === "direct" ? (
<label className="nodedc-ai-settings-field">
<span>Адрес агента</span>
<input value={draft.endpoint} placeholder="http://192.168.1.50:8787" onChange={(event) => setDraft((current) => ({ ...current, endpoint: event.target.value }))} />
</label>
) : null}
<label className="nodedc-ai-settings-field">
<span>Workspace path</span>
<input value={draft.workspacePath} placeholder="C:\\Projects\\NODEDC" onChange={(event) => setDraft((current) => ({ ...current, workspacePath: event.target.value }))} />
</label>
<label className="nodedc-ai-settings-field">
<span>Порт агента</span>
<input value={draft.agentPort} inputMode="numeric" onChange={(event) => setDraft((current) => ({ ...current, agentPort: event.target.value }))} />
</label>
<label className="nodedc-ai-settings-field">
<span>Аккаунт</span>
<input value={draft.accountLabel} placeholder="OpenAI account" onChange={(event) => setDraft((current) => ({ ...current, accountLabel: event.target.value }))} />
</label>
<div className="nodedc-ai-settings-field">
<span>Capabilities</span>
<div className="nodedc-ai-settings-capabilities">
{capabilityOptions.map((option) => (
<label key={option.value}>
<input type="checkbox" checked={draft.capabilities.includes(option.value)} onChange={() => toggleCapability(option.value)} />
<span>{option.label}</span>
</label>
))}
</div>
</div>
<div className="nodedc-ai-settings-actions">
{detailsMode === "edit" && editingExecutor ? (
<button type="button" className="modal-btn nodedc-ai-danger" disabled={isBusy} onClick={deleteEditingExecutor}>
<Trash2 className="size-4" />
Удалить
</button>
) : null}
<button type="submit" className="modal-btn btn-primary" disabled={isBusy || !draft.name.trim()}>
{busyId === "save" ? "Сохранение..." : detailsMode === "new" ? "Подключить" : "Сохранить"}
<button
type="button"
className="ai-console-icon-button"
aria-label="Подключить устройство"
title="Подключить устройство"
onClick={startNew}
>
<span aria-hidden="true">+</span>
</button>
</div>
</form>
) : null}
{isLoading ? <div className="ai-settings-empty-list">Загрузка</div> : null}
{executors.map((executor) => (
<button
key={executor.id}
type="button"
className="ai-settings-executor-row"
data-active={activeExecutor?.id === executor.id && detailsMode !== "new" ? "true" : undefined}
onClick={() => selectExecutor(executor)}
>
<span className="ai-settings-executor-row__title">
<span>{executor.name}</span>
<i className="ai-status-dot" data-status={executor.status} aria-hidden="true" />
</span>
<small>Codex worker · {statusLabel(executor.status)}</small>
</button>
))}
{!isLoading && !executors.length ? (
<div className="ai-settings-empty-list">
<span>{error ? "Registry недоступен" : "Устройств нет"}</span>
<small>
{error
? String((error as any)?.message || (error as any)?.error || error)
: "Подключенные устройства появятся здесь."}
</small>
</div>
) : null}
</aside>
<section className="ai-settings-context-pane">
<div className="ai-settings-form__top">
<div>
<div className="ai-settings-section-title">
{detailsMode === "new" ? "Новое устройство" : "Рабочий контекст"}
</div>
<div className="ai-settings-subtitle">Устройство, режим и контекст управления</div>
</div>
{activeExecutor ? (
<span
className="ai-executor-status ai-status-dot"
data-status={activeExecutor.status}
aria-label={statusLabel(activeExecutor.status)}
title={statusLabel(activeExecutor.status)}
/>
) : null}
</div>
{activeExecutor && detailsMode !== "new" ? (
<>
<div className="ai-settings-active-model">
<div>
<span>Активное устройство</span>
<b>{activeExecutor.name}</b>
<small>Codex worker · Codex Remote</small>
</div>
</div>
<div className="ai-settings-context-note ai-settings-context-note--remote">
Прямое управление Codex-сеансами устройства.
</div>
<div className="modal-actions ai-settings-actions">
<button type="button" className="modal-btn" disabled={isBusy} onClick={editActive}>
Настройки
</button>
<button type="button" className="modal-btn btn-primary" onClick={handleClose}>
Готово
</button>
</div>
</>
) : detailsMode === "new" ? (
<div className="ai-settings-empty-context">
<span>Новое устройство</span>
<small>
Заполни параметры справа и нажми «Подключить». После сохранения появятся pairing code и installer
агента.
</small>
</div>
) : error ? (
<div className="ai-settings-empty-context" data-tone="error">
<span>Registry backend недоступен</span>
<small>{String((error as any)?.message || (error as any)?.error || error)}</small>
<button type="button" className="modal-btn btn-primary" onClick={() => void mutate()}>
Повторить
</button>
</div>
) : (
<div className="ai-settings-empty-context">
<span>Устройство не выбрано</span>
<small>
Подключи Codex worker. Если устройство уже добавлено в Engine, оно появится здесь автоматически.
</small>
<button type="button" className="modal-btn btn-primary" onClick={startNew}>
Подключить устройство
</button>
</div>
)}
{notice ? (
<div className="ai-settings-check-note" data-tone="success">
{notice}
</div>
) : null}
{actionError ? <div className="ai-settings-error">{actionError}</div> : null}
</section>
{detailsOpen ? (
<form className="ai-settings-form ai-settings-details-pane" onSubmit={saveDraft}>
<div className="ai-settings-form__top">
<div>
<div className="ai-settings-section-title">
{detailsMode === "new" ? "Подключить устройство" : "Параметры устройства"}
</div>
<div className="ai-settings-subtitle">Cloud Hub или прямой endpoint без секретов</div>
</div>
{detailsMode === "edit" ? (
<button
type="button"
className="ai-settings-mini-close"
aria-label="Закрыть параметры устройства"
onClick={() => setDetailsMode("context")}
>
<span className="nodedc-glass-modal__close-mark" aria-hidden="true" />
</button>
) : null}
</div>
<label className="ai-settings-field">
<span>Имя устройства</span>
<input
className="ai-settings-input"
value={draft.name}
placeholder="MACPRO Codex"
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
/>
</label>
<label className="ai-settings-field">
<span>Тип</span>
<input className="ai-settings-input" value="Codex worker" readOnly />
</label>
<label className="ai-settings-field">
<span>Подключение</span>
<select
className="ai-settings-input"
value={draft.connectionMode}
onChange={(event) =>
setDraft((current) => ({
...current,
connectionMode: event.target.value as TAIWorkspaceExecutorConnectionMode,
}))
}
>
<option value="hub">Cloud Hub</option>
<option value="direct">Прямое подключение</option>
</select>
</label>
{draft.connectionMode === "hub" && detailsMode === "edit" && editingExecutor ? (
<div className="ai-settings-field-row ai-settings-field-row--download">
<label className="ai-settings-field">
<span>Pairing code</span>
<input
className="ai-settings-input"
value={editingExecutor.pairingCode || ""}
readOnly
placeholder="XXXX-XXXX-XXXX"
/>
</label>
<a
className="modal-btn ai-settings-download ai-settings-inline-download"
href={workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(
workspaceSlug,
editingExecutor.id
)}
onClick={downloadAgent}
download
>
Скачать агент
</a>
</div>
) : draft.connectionMode === "hub" ? (
<div className="ai-settings-check-note">
После сохранения общий AI Workspace создаст pairing code и installer агента.
</div>
) : null}
{draft.connectionMode === "direct" ? (
<label className="ai-settings-field">
<span>Адрес агента</span>
<input
className="ai-settings-input"
value={draft.endpoint}
placeholder="http://192.168.1.50:8787"
onChange={(event) => setDraft((current) => ({ ...current, endpoint: event.target.value }))}
/>
</label>
) : 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">
<span>Аккаунт</span>
<input
className="ai-settings-input"
value={draft.accountLabel}
placeholder="OpenAI account"
onChange={(event) => setDraft((current) => ({ ...current, accountLabel: event.target.value }))}
/>
</label>
<div className="ai-settings-field">
<span>Capabilities</span>
<div className="ai-settings-capabilities">
{capabilityOptions.map((option) => (
<label key={option.value} className="ai-settings-chip">
<input
type="checkbox"
checked={draft.capabilities.includes(option.value)}
onChange={() => toggleCapability(option.value)}
/>
<span>{option.label}</span>
</label>
))}
</div>
</div>
<div className="modal-actions ai-settings-actions">
{detailsMode === "edit" && editingExecutor ? (
<button
type="button"
className="modal-btn ai-settings-danger"
disabled={isBusy}
onClick={deleteEditingExecutor}
>
Удалить
</button>
) : null}
<button type="submit" className="modal-btn btn-primary" disabled={isBusy || !draft.name.trim()}>
{busyId === "save" ? "Сохранение..." : detailsMode === "new" ? "Подключить" : "Сохранить"}
</button>
</div>
</form>
) : null}
</div>
</div>
</div>
</ModalCore>