refactor: split ops ai workspace settings modal

This commit is contained in:
DCCONSTRUCTIONS 2026-06-09 14:06:14 +03:00
parent 097012ae31
commit 342df60c24
2 changed files with 613 additions and 522 deletions

View File

@ -4,101 +4,31 @@
* See the LICENSE file for details.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import type { FormEvent, MouseEvent } from "react";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import useSWR from "swr";
import { MessageCircle } from "lucide-react";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// components
import { AIWorkspaceProductSettingsModal } from "@/components/ai-workspace/product-settings-modal";
// services
import { WorkspaceAIWorkspaceService } from "@/services/workspace-ai-workspace.service";
import type {
TAIWorkspaceExecutor,
TAIWorkspaceExecutorConnectionMode,
TAIWorkspaceExecutorInput,
TAIWorkspaceExecutorListResponse,
} from "@/services/workspace-ai-workspace.service";
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: "mcp", label: "MCP" },
{ value: "workflows", label: "Workflows" },
];
type TAIWorkspaceGlobalControlProps = {
workspaceSlug: string;
};
type TExecutorDraft = {
name: string;
connectionMode: TAIWorkspaceExecutorConnectionMode;
endpoint: string;
workspacePath: string;
agentPort: string;
accountLabel: string;
capabilities: string[];
};
function emptyDraft(): TExecutorDraft {
return {
name: "",
connectionMode: "hub",
endpoint: "",
workspacePath: "",
agentPort: DEFAULT_WINDOWS_AGENT_PORT,
accountLabel: "",
capabilities: ["ops"],
};
}
function draftFromExecutor(executor: TAIWorkspaceExecutor | null | undefined): TExecutorDraft {
if (!executor) return emptyDraft();
return {
name: executor.name || "",
connectionMode: executor.connectionMode === "direct" ? "direct" : "hub",
endpoint: executor.endpoint || "",
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"],
};
}
function toInput(draft: TExecutorDraft): TAIWorkspaceExecutorInput {
const port = Number(draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
return {
name: draft.name.trim(),
type: "codex-remote",
connectionMode: draft.connectionMode,
endpoint: draft.connectionMode === "direct" ? draft.endpoint.trim() || null : null,
workspacePath: draft.workspacePath.trim() || null,
agentPort: Number.isInteger(port) ? port : Number(DEFAULT_WINDOWS_AGENT_PORT),
accountLabel: draft.accountLabel.trim() || null,
capabilities: Array.from(new Set(["ops", ...draft.capabilities])).filter(Boolean),
};
}
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";
}
export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalControlProps) {
const [isOpen, setIsOpen] = useState(false);
const [dockSlot, setDockSlot] = useState<Element | null>(null);
const [detailsMode, setDetailsMode] = useState<"context" | "new" | "edit">("context");
const [editingId, setEditingId] = useState<string>("");
const [draft, setDraft] = useState<TExecutorDraft>(() => emptyDraft());
const [busyId, setBusyId] = useState("");
const [notice, setNotice] = useState("");
const [actionError, setActionError] = useState("");
@ -128,163 +58,97 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo
};
}, []);
const executors = useMemo(() => data?.executors ?? [], [data?.executors]);
const activeExecutor = useMemo(
() => executors.find((executor) => executor.id === data?.selectedExecutorId) ?? executors[0] ?? null,
[data?.selectedExecutorId, executors]
);
const editingExecutor = useMemo(
() => executors.find((executor) => executor.id === editingId) ?? activeExecutor,
[activeExecutor, editingId, executors]
);
const detailsOpen = detailsMode === "new" || detailsMode === "edit";
const isBusy = Boolean(busyId);
useEffect(() => {
if (!isOpen || detailsMode === "new") return;
setEditingId(activeExecutor?.id || "");
setDraft(draftFromExecutor(activeExecutor));
}, [activeExecutor, detailsMode, isOpen]);
const handleOpen = useCallback(() => {
setIsOpen(true);
}, []);
const handleClose = useCallback(() => {
setIsOpen(false);
setDetailsMode("context");
setNotice("");
setActionError("");
}, []);
const runMutation = useCallback(async (id: string, action: () => Promise<unknown>, successText: string) => {
const runMutation = useCallback(
async <T,>(id: string, action: () => Promise<T>, successText: string): Promise<T | null> => {
setBusyId(id);
setActionError("");
setNotice("");
try {
await action();
const result = await action();
setNotice(successText);
return result;
} catch (mutationError: any) {
setActionError(
String(mutationError?.message || mutationError?.error || mutationError || "AI Workspace request failed")
);
return null;
} finally {
setBusyId("");
}
}, []);
},
[]
);
const startNew = () => {
setDetailsMode("new");
setEditingId("");
setDraft(emptyDraft());
setNotice("");
setActionError("");
};
const updateRegistry = useCallback(
async (payload: TAIWorkspaceExecutorListResponse) => {
await mutate(payload, { revalidate: false });
return payload;
},
[mutate]
);
const selectExecutor = (executor: TAIWorkspaceExecutor) => {
setDetailsMode("context");
setEditingId(executor.id);
setDraft(draftFromExecutor(executor));
if (executor.id !== data?.selectedExecutorId) {
void runMutation(
const selectExecutor = useCallback(
async (executor: TAIWorkspaceExecutor) =>
runMutation(
`select:${executor.id}`,
async () => {
const payload = await workspaceAIWorkspaceService.selectExecutor(workspaceSlug, executor.id);
await mutate(payload, { revalidate: false });
},
async () => updateRegistry(await workspaceAIWorkspaceService.selectExecutor(workspaceSlug, executor.id)),
"Устройство выбрано"
),
[runMutation, updateRegistry, workspaceSlug]
);
}
};
const editActive = () => {
if (!activeExecutor) return;
setDetailsMode("edit");
setEditingId(activeExecutor.id);
setDraft(draftFromExecutor(activeExecutor));
setNotice("");
setActionError("");
};
const saveDraft = async (event: FormEvent) => {
event.preventDefault();
const input = toInput(draft);
if (!input.name) {
setActionError("Укажи имя устройства");
return;
}
if (detailsMode === "new") {
await runMutation(
const createExecutor = useCallback(
async (input: TAIWorkspaceExecutorInput) =>
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");
},
async () => updateRegistry(await workspaceAIWorkspaceService.createExecutor(workspaceSlug, input)),
"Устройство создано"
),
[runMutation, updateRegistry, workspaceSlug]
);
return;
}
if (!editingExecutor) return;
await runMutation(
const updateExecutor = useCallback(
async (executorId: string, input: TAIWorkspaceExecutorInput) =>
runMutation(
"save",
async () => {
const payload = await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, editingExecutor.id, input);
await mutate(payload, { revalidate: false });
setDetailsMode("context");
},
async () => updateRegistry(await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, executorId, input)),
"Устройство сохранено"
),
[runMutation, updateRegistry, workspaceSlug]
);
};
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");
},
const deleteExecutor = useCallback(
async (executorId: string) =>
runMutation(
`delete:${executorId}`,
async () => updateRegistry(await workspaceAIWorkspaceService.deleteExecutor(workspaceSlug, executorId)),
"Устройство удалено"
),
[runMutation, updateRegistry, workspaceSlug]
);
};
const toggleCapability = (value: string) => {
setDraft((current) => {
const set = new Set(current.capabilities);
if (set.has(value)) set.delete(value);
else set.add(value);
return { ...current, capabilities: Array.from(set) };
});
};
const downloadAgent = async (event: MouseEvent<HTMLAnchorElement>) => {
if (!editingExecutor) return;
event.preventDefault();
const downloadAgent = useCallback(
async (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => {
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,
}
);
const payload = await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, executorId, input);
await updateRegistry(payload);
window.location.href = workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, executorId, {
port,
});
},
"Installer подготовлен"
);
};
},
[runMutation, updateRegistry, workspaceSlug]
);
return (
<>
@ -294,7 +158,7 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo
<button
type="button"
className="nodedc-bottom-dock-voice-button nodedc-bottom-dock-ai-workspace-button"
onClick={handleOpen}
onClick={() => setIsOpen(true)}
aria-label="AI Workspace"
>
<MessageCircle className="size-4" />
@ -304,311 +168,26 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo
)
: null}
<ModalCore
<AIWorkspaceProductSettingsModal
isOpen={isOpen}
handleClose={handleClose}
position={EModalPosition.CENTER}
width={EModalWidth.XXL}
className="overflow-visible"
>
<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="nodedc-glass-modal__close" onClick={handleClose} aria-label="Закрыть">
<span className="nodedc-glass-modal__close-mark" aria-hidden="true" />
</button>
</div>
<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="ai-console-kicker">AI Space</div>
<div className="ai-settings-devices-title">Устройства</div>
</div>
<button
type="button"
className="ai-console-icon-button"
aria-label="Подключить устройство"
title="Подключить устройство"
onClick={startNew}
>
<span aria-hidden="true">+</span>
</button>
</div>
{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,
}))
executors={data?.executors ?? []}
selectedExecutorId={data?.selectedExecutorId ?? null}
isLoading={isLoading}
error={error}
notice={notice}
actionError={actionError}
busyId={busyId}
onClose={handleClose}
onReload={() => mutate()}
onSelect={selectExecutor}
onCreate={createExecutor}
onUpdate={updateExecutor}
onDelete={deleteExecutor}
onDownloadAgent={downloadAgent}
getWindowsAgentInstallerUrl={(executorId, options) =>
workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, executorId, options)
}
>
<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>
</>
);
}

View File

@ -0,0 +1,512 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useMemo, useState } from "react";
import type { FormEvent, MouseEvent } from "react";
// plane imports
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// services
import type {
TAIWorkspaceExecutor,
TAIWorkspaceExecutorConnectionMode,
TAIWorkspaceExecutorInput,
TAIWorkspaceExecutorListResponse,
} from "@/services/workspace-ai-workspace.service";
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: "mcp", label: "MCP" },
{ value: "workflows", label: "Workflows" },
];
type TExecutorDraft = {
name: string;
connectionMode: TAIWorkspaceExecutorConnectionMode;
endpoint: string;
workspacePath: string;
agentPort: string;
accountLabel: string;
capabilities: string[];
};
type TAIWorkspaceProductSettingsModalProps = {
isOpen: boolean;
executors: TAIWorkspaceExecutor[];
selectedExecutorId?: string | null;
isLoading?: boolean;
error?: unknown;
notice?: string;
actionError?: string;
busyId?: string;
onClose: () => void;
onReload: () => Promise<unknown> | unknown;
onSelect: (executor: TAIWorkspaceExecutor) => Promise<unknown> | unknown;
onCreate: (input: TAIWorkspaceExecutorInput) => Promise<TAIWorkspaceExecutorListResponse | null>;
onUpdate: (executorId: string, input: TAIWorkspaceExecutorInput) => Promise<TAIWorkspaceExecutorListResponse | null>;
onDelete: (executorId: string) => Promise<TAIWorkspaceExecutorListResponse | null>;
onDownloadAgent: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise<void>;
getWindowsAgentInstallerUrl: (executorId: string, options?: { port?: string | number }) => string;
};
function emptyDraft(): TExecutorDraft {
return {
name: "",
connectionMode: "hub",
endpoint: "",
workspacePath: "",
agentPort: DEFAULT_WINDOWS_AGENT_PORT,
accountLabel: "",
capabilities: ["ops"],
};
}
function draftFromExecutor(executor: TAIWorkspaceExecutor | null | undefined): TExecutorDraft {
if (!executor) return emptyDraft();
return {
name: executor.name || "",
connectionMode: executor.connectionMode === "direct" ? "direct" : "hub",
endpoint: executor.endpoint || "",
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"],
};
}
function toInput(draft: TExecutorDraft): TAIWorkspaceExecutorInput {
const port = Number(draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
return {
name: draft.name.trim(),
type: "codex-remote",
connectionMode: draft.connectionMode,
endpoint: draft.connectionMode === "direct" ? draft.endpoint.trim() || null : null,
workspacePath: draft.workspacePath.trim() || null,
agentPort: Number.isInteger(port) ? port : Number(DEFAULT_WINDOWS_AGENT_PORT),
accountLabel: draft.accountLabel.trim() || null,
capabilities: Array.from(new Set(["ops", ...draft.capabilities])).filter(Boolean),
};
}
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";
}
function errorText(error: unknown) {
if (!error) return "";
return String((error as any)?.message || (error as any)?.error || error);
}
export function AIWorkspaceProductSettingsModal({
isOpen,
executors,
selectedExecutorId,
isLoading,
error,
notice,
actionError,
busyId,
onClose,
onReload,
onSelect,
onCreate,
onUpdate,
onDelete,
onDownloadAgent,
getWindowsAgentInstallerUrl,
}: TAIWorkspaceProductSettingsModalProps) {
const activeExecutor = useMemo(
() => executors.find((executor) => executor.id === selectedExecutorId) ?? executors[0] ?? null,
[executors, selectedExecutorId]
);
const [detailsMode, setDetailsMode] = useState<"context" | "new" | "edit">("context");
const [editingId, setEditingId] = useState<string>("");
const [draft, setDraft] = useState<TExecutorDraft>(() => draftFromExecutor(activeExecutor));
const editingExecutor = useMemo(
() => executors.find((executor) => executor.id === editingId) ?? activeExecutor,
[activeExecutor, editingId, executors]
);
const detailsOpen = detailsMode === "new" || detailsMode === "edit";
const isBusy = Boolean(busyId);
useEffect(() => {
if (!isOpen) {
setDetailsMode("context");
return;
}
if (detailsMode === "new") return;
setEditingId(activeExecutor?.id || "");
setDraft(draftFromExecutor(activeExecutor));
}, [activeExecutor, detailsMode, isOpen]);
const startNew = () => {
setDetailsMode("new");
setEditingId("");
setDraft(emptyDraft());
};
const selectExecutor = (executor: TAIWorkspaceExecutor) => {
setDetailsMode("context");
setEditingId(executor.id);
setDraft(draftFromExecutor(executor));
if (executor.id !== selectedExecutorId) void onSelect(executor);
};
const editActive = () => {
if (!activeExecutor) return;
setDetailsMode("edit");
setEditingId(activeExecutor.id);
setDraft(draftFromExecutor(activeExecutor));
};
const saveDraft = async (event: FormEvent) => {
event.preventDefault();
const input = toInput(draft);
if (!input.name) return;
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;
setEditingId(next?.id || "");
setDetailsMode(next ? "edit" : "context");
return;
}
if (!editingExecutor) return;
const payload = await onUpdate(editingExecutor.id, input);
if (payload) setDetailsMode("context");
};
const deleteEditingExecutor = async () => {
if (!editingExecutor) return;
const payload = await onDelete(editingExecutor.id);
if (payload) setDetailsMode("context");
};
const toggleCapability = (value: string) => {
setDraft((current) => {
const set = new Set(current.capabilities);
if (set.has(value)) set.delete(value);
else set.add(value);
return { ...current, capabilities: Array.from(set) };
});
};
const downloadAgent = async (event: MouseEvent<HTMLAnchorElement>) => {
if (!editingExecutor) return;
event.preventDefault();
await onDownloadAgent(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
};
return (
<ModalCore
isOpen={isOpen}
handleClose={onClose}
position={EModalPosition.CENTER}
width={EModalWidth.XXL}
className="overflow-visible"
>
<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="nodedc-glass-modal__close" onClick={onClose} aria-label="Закрыть">
<span className="nodedc-glass-modal__close-mark" aria-hidden="true" />
</button>
</div>
<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="ai-console-kicker">AI Space</div>
<div className="ai-settings-devices-title">Устройства</div>
</div>
<button
type="button"
className="ai-console-icon-button"
aria-label="Подключить устройство"
title="Подключить устройство"
onClick={startNew}
>
<span aria-hidden="true">+</span>
</button>
</div>
{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 ? errorText(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={onClose}>
Готово
</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>{errorText(error)}</small>
<button type="button" className="modal-btn btn-primary" onClick={() => void onReload()}>
Повторить
</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={getWindowsAgentInstallerUrl(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>
);
}