From 71979526cfc7947b563bdeb89b694d02046aedf3 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Tue, 9 Jun 2026 13:23:02 +0300 Subject: [PATCH] feat: add standalone ops ai workspace setup --- .../apps/api/plane/app/urls/ai_workspace.py | 6 + .../apps/api/plane/app/views/__init__.py | 1 + .../apps/api/plane/app/views/ai_workspace.py | 43 ++ .../ai-workspace/global-control.tsx | 457 +++++++++++++++--- .../workspace-ai-workspace.service.ts | 63 ++- plane-src/apps/web/styles/globals.css | 303 ++++++++++++ 6 files changed, 810 insertions(+), 63 deletions(-) diff --git a/plane-src/apps/api/plane/app/urls/ai_workspace.py b/plane-src/apps/api/plane/app/urls/ai_workspace.py index ea8e62a..fb4bc04 100644 --- a/plane-src/apps/api/plane/app/urls/ai_workspace.py +++ b/plane-src/apps/api/plane/app/urls/ai_workspace.py @@ -8,6 +8,7 @@ from plane.app.views import ( AIWorkspaceExecutorDetailEndpoint, AIWorkspaceExecutorListEndpoint, AIWorkspaceExecutorSelectEndpoint, + AIWorkspaceExecutorWindowsAgentEndpoint, AIWorkspaceThreadDetailEndpoint, AIWorkspaceThreadListEndpoint, AIWorkspaceThreadMessagesEndpoint, @@ -30,6 +31,11 @@ urlpatterns = [ AIWorkspaceExecutorSelectEndpoint.as_view(), name="ai-workspace-executor-select", ), + path( + "workspaces//ai-workspace/executors//agent/windows.ps1", + AIWorkspaceExecutorWindowsAgentEndpoint.as_view(), + name="ai-workspace-executor-windows-agent", + ), path( "workspaces//ai-workspace/threads/", AIWorkspaceThreadListEndpoint.as_view(), diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index 279459c..6297e25 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -178,6 +178,7 @@ from .ai_workspace import ( AIWorkspaceExecutorDetailEndpoint, AIWorkspaceExecutorListEndpoint, AIWorkspaceExecutorSelectEndpoint, + AIWorkspaceExecutorWindowsAgentEndpoint, AIWorkspaceThreadDetailEndpoint, AIWorkspaceThreadListEndpoint, AIWorkspaceThreadMessagesEndpoint, diff --git a/plane-src/apps/api/plane/app/views/ai_workspace.py b/plane-src/apps/api/plane/app/views/ai_workspace.py index a34261f..e2c8d68 100644 --- a/plane-src/apps/api/plane/app/views/ai_workspace.py +++ b/plane-src/apps/api/plane/app/views/ai_workspace.py @@ -3,6 +3,7 @@ import os # Third party imports import requests +from django.http import HttpResponse from rest_framework import status from rest_framework.response import Response @@ -138,6 +139,48 @@ class AIWorkspaceExecutorSelectEndpoint(BaseAPIView): return ai_workspace_request(request, "POST", f"/executors/{executor_id}/select") +class AIWorkspaceExecutorWindowsAgentEndpoint(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def get(self, request, slug, executor_id): + config, error_response = require_ai_workspace_config() + if error_response is not None: + return error_response + + base_url, token, timeout = config + params = {} + if request.query_params.get("port"): + params["port"] = request.query_params.get("port") + try: + response = requests.get( + f"{base_url}/api/ai-workspace/assistant/v1/executors/{executor_id}/agent/windows.ps1", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/octet-stream, text/plain, */*", + **user_headers(request.user), + }, + params=params, + timeout=timeout, + ) + except requests.RequestException: + return Response( + { + "ok": False, + "error": "ai_workspace_assistant_unavailable", + "message": "NODE.DC AI Workspace Assistant is unavailable.", + }, + status=status.HTTP_502_BAD_GATEWAY, + ) + + if response.status_code >= 400: + return HttpResponse(response.content, status=response.status_code, content_type=response.headers.get("Content-Type", "text/plain")) + + output = HttpResponse(response.content, status=response.status_code, content_type=response.headers.get("Content-Type", "application/octet-stream")) + for header in ("Content-Disposition", "Cache-Control", "Pragma", "Expires", "Surrogate-Control", "X-AI-Workspace-Installer-SHA256"): + if response.headers.get(header): + output[header] = response.headers[header] + return output + + class AIWorkspaceThreadListEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug): diff --git a/plane-src/apps/web/core/components/ai-workspace/global-control.tsx b/plane-src/apps/web/core/components/ai-workspace/global-control.tsx index 56de57b..ac3c5a9 100644 --- a/plane-src/apps/web/core/components/ai-workspace/global-control.tsx +++ b/plane-src/apps/web/core/components/ai-workspace/global-control.tsx @@ -5,18 +5,29 @@ */ import { useCallback, useEffect, useMemo, useState } from "react"; +import type { FormEvent, MouseEvent } from "react"; import { createPortal } from "react-dom"; import useSWR from "swr"; -import { CheckCircle2, MessageCircle, Monitor, RefreshCw, X } from "lucide-react"; +import { CheckCircle2, Download, MessageCircle, Monitor, Plus, RefreshCw, Settings, Trash2, X } from "lucide-react"; // plane imports -import { Button } from "@plane/propel/button"; 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 { + TAIWorkspaceExecutor, + TAIWorkspaceExecutorConnectionMode, + TAIWorkspaceExecutorInput, +} from "@/services/workspace-ai-workspace.service"; const workspaceAIWorkspaceService = new WorkspaceAIWorkspaceService(); +const DEFAULT_WINDOWS_AGENT_PORT = "8787"; +const capabilityOptions = [ + { value: "engine", label: "Engine" }, + { value: "ops", label: "Ops" }, + { value: "ndc-agent-core", label: "NDC Agent" }, +]; 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]"; @@ -25,9 +36,72 @@ 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(null); + const [detailsMode, setDetailsMode] = useState<"context" | "new" | "edit">("context"); + const [editingId, setEditingId] = useState(""); + const [draft, setDraft] = useState(() => emptyDraft()); + const [busyId, setBusyId] = useState(""); + const [notice, setNotice] = useState(""); + const [actionError, setActionError] = useState(""); const { data, error, isLoading, mutate } = useSWR( isOpen && workspaceSlug ? `AI_WORKSPACE_EXECUTORS_${workspaceSlug}` : null, @@ -54,10 +128,23 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo }; }, []); - const selectedExecutor = useMemo( - () => data?.executors?.find((executor) => executor.id === data.selectedExecutorId) ?? data?.executors?.[0] ?? null, - [data?.executors, data?.selectedExecutorId] + 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); @@ -65,8 +152,116 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo const handleClose = useCallback(() => { setIsOpen(false); + setDetailsMode("context"); + setNotice(""); + setActionError(""); }, []); + const runMutation = useCallback( + async (id: string, action: () => Promise, 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"); + setEditingId(""); + setDraft(emptyDraft()); + setNotice(""); + setActionError(""); + }; + + const selectExecutor = (executor: TAIWorkspaceExecutor) => { + setDetailsMode("context"); + setEditingId(executor.id); + setDraft(draftFromExecutor(executor)); + if (executor.id !== data?.selectedExecutorId) { + void runMutation( + `select:${executor.id}`, + async () => { + const payload = await workspaceAIWorkspaceService.selectExecutor(workspaceSlug, executor.id); + await mutate(payload, { revalidate: false }); + }, + "Устройство выбрано" + ); + } + }; + + 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("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"); + }, "Устройство сохранено"); + }; + + 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 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) => { + 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 подготовлен"); + }; + return ( <> {dockSlot @@ -89,79 +284,217 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} - width={EModalWidth.MD} + width={EModalWidth.XXL} className="overflow-visible" > -
-
+
+
-

AI Workspace

-
Ops
+
AI Space
+

AI Workspace

-
-
-
-
- - - -
-
- {selectedExecutor?.name ?? "Codex assistant"} -
-
- {selectedExecutor?.model || selectedExecutor?.accountLabel || "AI Workspace"} -
+
+
-
- {error ? ( -
- {error?.message || error?.error || "AI Workspace недоступен"} + {isLoading ?
Загрузка
: null} + {executors.map((executor) => ( + + ))} + {!isLoading && !executors.length ? ( +
+ {error ? "Registry недоступен" : "Устройств нет"} + {error ? String((error as any)?.message || (error as any)?.error || error) : "Подключенные устройства появятся здесь."}
- ) : isLoading ? ( -
Загрузка
- ) : data?.executors?.length ? ( - data.executors.map((executor) => ( -
-
- -
-
{executor.name}
-
- {executor.connectionMode === "hub" ? executor.pairingCode || "hub" : "direct"} -
-
-
- - - {executor.status} - + ) : null} + + +
+
+
+
{detailsMode === "new" ? "Новое устройство" : "Рабочий контекст"}
+
Общий пользовательский ассистент для Ops и Engine
+
+ {activeExecutor ? {statusLabel(activeExecutor.status)} : null} +
+ + {activeExecutor && detailsMode !== "new" ? ( + <> +
+ Активное устройство + {activeExecutor.name} + {activeExecutor.connectionMode === "hub" ? activeExecutor.pairingCode || "Cloud Hub" : activeExecutor.endpoint}
- )) + +
+ Это устройство сохранено в общем AI Workspace registry. Оно будет видно в Engine и Ops для этого же пользователя. +
+ +
+ + + +
+ + ) : detailsMode === "new" ? ( +
+ Новое устройство + Заполни параметры справа и нажми «Подключить». После сохранения появятся pairing code и installer агента. +
+ ) : error ? ( +
+ Registry backend недоступен + {String((error as any)?.message || (error as any)?.error || error)} + +
) : ( -
- Устройства не подключены +
+ Устройство не выбрано + Подключи Codex worker. Если устройство уже добавлено в Engine, оно появится здесь автоматически. +
)} -
+ + {notice ?
{notice}
: null} + {actionError ?
{actionError}
: null} +
+ + {detailsOpen ? ( +
+
+
+
{detailsMode === "new" ? "Подключить устройство" : "Параметры устройства"}
+
Cloud Hub или прямой endpoint
+
+ {detailsMode === "edit" ? ( + + ) : null} +
+ + + + + + {draft.connectionMode === "hub" && detailsMode === "edit" && editingExecutor ? ( +
+ + + + Скачать агент + +
+ ) : draft.connectionMode === "hub" ? ( +
+ После сохранения общий AI Workspace создаст pairing code и installer агента. +
+ ) : null} + + {draft.connectionMode === "direct" ? ( + + ) : null} + + + + + + + +
+ Capabilities +
+ {capabilityOptions.map((option) => ( + + ))} +
+
+ +
+ {detailsMode === "edit" && editingExecutor ? ( + + ) : null} + +
+
+ ) : null}
diff --git a/plane-src/apps/web/core/services/workspace-ai-workspace.service.ts b/plane-src/apps/web/core/services/workspace-ai-workspace.service.ts index bca90bf..9a12632 100644 --- a/plane-src/apps/web/core/services/workspace-ai-workspace.service.ts +++ b/plane-src/apps/web/core/services/workspace-ai-workspace.service.ts @@ -8,17 +8,24 @@ import { API_BASE_URL } from "@plane/constants"; import { APIService } from "@/services/api.service"; export type TAIWorkspaceExecutorStatus = "unknown" | "online" | "offline" | "checking" | "error"; +export type TAIWorkspaceExecutorConnectionMode = "hub" | "direct"; export type TAIWorkspaceExecutor = { id: string; name: string; type: string; - connectionMode: "hub" | "direct"; + connectionMode: TAIWorkspaceExecutorConnectionMode; + endpoint?: string | null; + workspacePath?: string | null; + agentPort?: number | null; pairingCode?: string | null; model?: string | null; accountLabel?: string | null; + capabilities?: string[]; status: TAIWorkspaceExecutorStatus; + statusDetail?: string | null; lastSeenAt?: string | null; + createdAt?: string | null; updatedAt?: string | null; }; @@ -28,6 +35,21 @@ export type TAIWorkspaceExecutorListResponse = { executors: TAIWorkspaceExecutor[]; }; +export type TAIWorkspaceExecutorInput = { + id?: string; + name: string; + type?: string; + connectionMode?: TAIWorkspaceExecutorConnectionMode; + endpoint?: string | null; + workspacePath?: string | null; + agentPort?: number | null; + pairingCode?: string | null; + model?: string | null; + accountLabel?: string | null; + capabilities?: string[]; + status?: TAIWorkspaceExecutorStatus; +}; + export class WorkspaceAIWorkspaceService extends APIService { constructor() { super(API_BASE_URL); @@ -40,4 +62,43 @@ export class WorkspaceAIWorkspaceService extends APIService { throw error?.response?.data ?? error; }); } + + async createExecutor(workspaceSlug: string, input: TAIWorkspaceExecutorInput): Promise { + await this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/`, { ...input, select: true }).catch((error) => { + throw error?.response?.data ?? error; + }); + return this.listExecutors(workspaceSlug); + } + + async updateExecutor( + workspaceSlug: string, + executorId: string, + input: Partial + ): Promise { + await this.patch(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/`, input).catch((error) => { + throw error?.response?.data ?? error; + }); + return this.listExecutors(workspaceSlug); + } + + async deleteExecutor(workspaceSlug: string, executorId: string): Promise { + await this.delete(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/`).catch((error) => { + throw error?.response?.data ?? error; + }); + return this.listExecutors(workspaceSlug); + } + + async selectExecutor(workspaceSlug: string, executorId: string): Promise { + await this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/select/`).catch((error) => { + throw error?.response?.data ?? error; + }); + return this.listExecutors(workspaceSlug); + } + + getWindowsAgentInstallerUrl(workspaceSlug: string, executorId: string, options: { port?: string | number } = {}) { + const params = new URLSearchParams({ v: `${Date.now()}` }); + const port = String(options.port || "").trim(); + if (port) params.set("port", port); + return `${API_BASE_URL}/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/agent/windows.ps1?${params.toString()}`; + } } diff --git a/plane-src/apps/web/styles/globals.css b/plane-src/apps/web/styles/globals.css index 7b0c49d..b28ce27 100644 --- a/plane-src/apps/web/styles/globals.css +++ b/plane-src/apps/web/styles/globals.css @@ -652,6 +652,309 @@ color: rgb(var(--nodedc-card-active-rgb)); } + .nodedc-ai-workspace-modal { + width: min(74rem, calc(100vw - 2rem)); + min-height: min(42rem, calc(100vh - 4rem)); + padding: 1.25rem; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 2rem; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.025)), + rgba(22, 23, 26, 0.94); + color: rgba(255, 255, 255, 0.92); + box-shadow: 0 2rem 5rem rgba(0, 0, 0, 0.42); + -webkit-backdrop-filter: blur(34px); + backdrop-filter: blur(34px); + } + + .nodedc-ai-workspace-modal__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding-right: 3.75rem; + } + + .nodedc-ai-workspace-modal__head h3 { + margin: 0; + font-size: 1.15rem; + font-weight: 700; + letter-spacing: 0; + } + + .nodedc-ai-workspace-kicker { + margin-bottom: 0.25rem; + color: rgb(var(--nodedc-accent-rgb)); + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; + } + + .nodedc-ai-settings-workspace { + display: grid; + grid-template-columns: minmax(14rem, 0.9fr) minmax(18rem, 1.2fr); + gap: 0.75rem; + margin-top: 1rem; + } + + .nodedc-ai-settings-workspace[data-details-open="true"] { + grid-template-columns: minmax(13rem, 0.8fr) minmax(17rem, 1fr) minmax(19rem, 1fr); + } + + .nodedc-ai-settings-device-list, + .nodedc-ai-settings-context-pane, + .nodedc-ai-settings-details-pane { + min-width: 0; + min-height: 28rem; + padding: 1rem; + border: 1px solid rgba(255, 255, 255, 0.075); + border-radius: 1.55rem; + background: rgba(255, 255, 255, 0.035); + } + + .nodedc-ai-settings-details-pane { + display: flex; + flex-direction: column; + gap: 0.8rem; + } + + .nodedc-ai-settings-devices-head, + .nodedc-ai-settings-form-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + } + + .nodedc-ai-settings-devices-title, + .nodedc-ai-settings-section-title { + font-size: 0.95rem; + font-weight: 750; + letter-spacing: 0; + } + + .nodedc-ai-settings-subtitle { + margin-top: 0.2rem; + color: rgba(255, 255, 255, 0.48); + font-size: 0.75rem; + line-height: 1.35; + } + + .nodedc-ai-icon-button, + .nodedc-ai-mini-close { + display: grid; + width: 2.5rem; + height: 2.5rem; + flex: 0 0 auto; + place-items: center; + border: 0; + border-radius: 999px; + background: rgba(7, 7, 10, 0.78); + color: rgba(255, 255, 255, 0.8); + } + + .nodedc-ai-settings-executor-row { + display: flex; + width: 100%; + min-height: 4rem; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: 0.3rem; + margin-top: 0.75rem; + padding: 0.8rem; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 1.1rem; + background: rgba(255, 255, 255, 0.035); + color: inherit; + text-align: left; + } + + .nodedc-ai-settings-executor-row[data-active="true"] { + border-color: rgba(var(--nodedc-accent-rgb), 0.52); + background: rgba(var(--nodedc-accent-rgb), 0.1); + } + + .nodedc-ai-settings-executor-row span { + display: flex; + min-width: 0; + align-items: center; + gap: 0.5rem; + } + + .nodedc-ai-settings-executor-row b, + .nodedc-ai-settings-active-model b { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .nodedc-ai-settings-executor-row small, + .nodedc-ai-settings-empty-list small, + .nodedc-ai-settings-empty-context small, + .nodedc-ai-settings-active-model small { + color: rgba(255, 255, 255, 0.5); + font-size: 0.72rem; + line-height: 1.35; + } + + .nodedc-ai-settings-empty-list, + .nodedc-ai-settings-empty-context, + .nodedc-ai-settings-active-model, + .nodedc-ai-settings-note, + .nodedc-ai-settings-error { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-top: 1rem; + padding: 1rem; + border-radius: 1.25rem; + background: rgba(255, 255, 255, 0.04); + } + + .nodedc-ai-settings-empty-context { + min-height: 18rem; + justify-content: center; + text-align: center; + } + + .nodedc-ai-settings-empty-context .modal-btn { + margin: 0.7rem auto 0; + } + + .nodedc-ai-settings-active-model span { + color: rgba(255, 255, 255, 0.52); + font-size: 0.72rem; + } + + .nodedc-ai-settings-note { + color: rgba(255, 255, 255, 0.66); + font-size: 0.75rem; + line-height: 1.4; + } + + .nodedc-ai-settings-note[data-ready="true"], + .nodedc-ai-settings-note[data-tone="success"] { + border: 1px solid rgba(var(--nodedc-accent-rgb), 0.18); + background: rgba(var(--nodedc-accent-rgb), 0.08); + } + + .nodedc-ai-settings-error, + .nodedc-ai-settings-empty-context[data-tone="error"] { + border: 1px solid rgba(255, 89, 89, 0.22); + background: rgba(255, 89, 89, 0.1); + color: rgb(255, 144, 144); + } + + .nodedc-ai-status-pill { + flex: 0 0 auto; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + padding: 0.35rem 0.6rem; + color: rgba(255, 255, 255, 0.62); + font-size: 0.68rem; + font-weight: 700; + } + + .nodedc-ai-settings-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-top: 1rem; + } + + .nodedc-ai-settings-actions .modal-btn, + .nodedc-ai-settings-download { + display: inline-flex; + min-height: 2.35rem; + align-items: center; + justify-content: center; + gap: 0.45rem; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.86); + padding: 0.55rem 0.85rem; + font-size: 0.78rem; + font-weight: 750; + } + + .nodedc-ai-settings-actions .btn-primary, + .nodedc-ai-settings-download.btn-primary { + background: rgb(var(--nodedc-accent-rgb)); + color: rgb(var(--nodedc-on-accent-rgb)); + } + + .nodedc-ai-danger { + color: rgb(255, 144, 144) !important; + } + + .nodedc-ai-settings-field { + display: flex; + flex-direction: column; + gap: 0.35rem; + color: rgba(255, 255, 255, 0.56); + font-size: 0.72rem; + font-weight: 700; + } + + .nodedc-ai-settings-field input, + .nodedc-ai-settings-field select { + min-height: 2.55rem; + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 0.9rem; + background: rgba(7, 7, 10, 0.54); + color: rgba(255, 255, 255, 0.9); + outline: none; + padding: 0 0.8rem; + font-size: 0.82rem; + } + + .nodedc-ai-settings-download-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 0.5rem; + } + + .nodedc-ai-settings-capabilities { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + } + + .nodedc-ai-settings-capabilities label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.72); + padding: 0.45rem 0.65rem; + font-size: 0.73rem; + } + + @media (max-width: 980px) { + .nodedc-ai-workspace-modal { + max-height: calc(100vh - 2rem); + overflow-y: auto; + } + + .nodedc-ai-settings-workspace, + .nodedc-ai-settings-workspace[data-details-open="true"] { + grid-template-columns: 1fr; + } + + .nodedc-ai-settings-device-list, + .nodedc-ai-settings-context-pane, + .nodedc-ai-settings-details-pane { + min-height: auto; + } + } + .nodedc-bottom-dock-aware-padding { padding-bottom: calc(var(--nodedc-quick-add-reserve, 2.5rem) + 0.5rem); }