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 4cd8014..d0c04a3 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 @@ -4,23 +4,13 @@ * See the LICENSE file for details. */ -import { useCallback, useEffect, useState } from "react"; +import { 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"; // components -import { AIWorkspaceProductSettingsModal } from "@/components/ai-workspace/product-settings-modal"; -// services -import { WorkspaceAIWorkspaceService } from "@/services/workspace-ai-workspace.service"; -import type { - TAIWorkspaceExecutor, - TAIWorkspaceExecutorInput, - TAIWorkspaceExecutorListResponse, -} from "@/services/workspace-ai-workspace.service"; - -const workspaceAIWorkspaceService = new WorkspaceAIWorkspaceService(); +import { AIWorkspaceProductConsole } from "@/components/ai-workspace/product-console"; type TAIWorkspaceGlobalControlProps = { workspaceSlug: string; @@ -29,15 +19,6 @@ type TAIWorkspaceGlobalControlProps = { export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalControlProps) { const [isOpen, setIsOpen] = useState(false); const [dockSlot, setDockSlot] = useState(null); - 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, - () => workspaceAIWorkspaceService.listExecutors(workspaceSlug), - { refreshInterval: isOpen ? 10000 : 0 } - ); useEffect(() => { if (typeof document === "undefined") return; @@ -58,98 +39,6 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo }; }, []); - const handleClose = useCallback(() => { - setIsOpen(false); - setNotice(""); - setActionError(""); - }, []); - - const runMutation = useCallback( - async (id: string, action: () => Promise, successText: string): Promise => { - setBusyId(id); - setActionError(""); - setNotice(""); - try { - 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 updateRegistry = useCallback( - async (payload: TAIWorkspaceExecutorListResponse) => { - await mutate(payload, { revalidate: false }); - return payload; - }, - [mutate] - ); - - const selectExecutor = useCallback( - async (executor: TAIWorkspaceExecutor) => - runMutation( - `select:${executor.id}`, - async () => updateRegistry(await workspaceAIWorkspaceService.selectExecutor(workspaceSlug, executor.id)), - "Устройство выбрано" - ), - [runMutation, updateRegistry, workspaceSlug] - ); - - const createExecutor = useCallback( - async (input: TAIWorkspaceExecutorInput) => - runMutation( - "save", - async () => updateRegistry(await workspaceAIWorkspaceService.createExecutor(workspaceSlug, input)), - "Устройство создано" - ), - [runMutation, updateRegistry, workspaceSlug] - ); - - const updateExecutor = useCallback( - async (executorId: string, input: TAIWorkspaceExecutorInput) => - runMutation( - "save", - async () => updateRegistry(await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, executorId, input)), - "Устройство сохранено" - ), - [runMutation, updateRegistry, workspaceSlug] - ); - - const deleteExecutor = useCallback( - async (executorId: string) => - runMutation( - `delete:${executorId}`, - async () => updateRegistry(await workspaceAIWorkspaceService.deleteExecutor(workspaceSlug, executorId)), - "Устройство удалено" - ), - [runMutation, updateRegistry, workspaceSlug] - ); - - const downloadAgent = useCallback( - async (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => { - await runMutation( - "download", - async () => { - const payload = await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, executorId, input); - await updateRegistry(payload); - window.location.href = workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, executorId, { - port, - }); - }, - "Installer подготовлен" - ); - }, - [runMutation, updateRegistry, workspaceSlug] - ); - return ( <> {dockSlot @@ -168,26 +57,7 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo ) : null} - mutate()} - onSelect={selectExecutor} - onCreate={createExecutor} - onUpdate={updateExecutor} - onDelete={deleteExecutor} - onDownloadAgent={downloadAgent} - getWindowsAgentInstallerUrl={(executorId, options) => - workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, executorId, options) - } - /> + setIsOpen(false)} /> ); } diff --git a/plane-src/apps/web/core/components/ai-workspace/product-console.tsx b/plane-src/apps/web/core/components/ai-workspace/product-console.tsx new file mode 100644 index 0000000..b325de6 --- /dev/null +++ b/plane-src/apps/web/core/components/ai-workspace/product-console.tsx @@ -0,0 +1,1111 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { CSSProperties, FormEvent, PointerEvent as ReactPointerEvent } from "react"; +import { createPortal } from "react-dom"; +// components +import { AIWorkspaceProductSettingsModal } from "@/components/ai-workspace/product-settings-modal"; +// services +import { WorkspaceAIWorkspaceService } from "@/services/workspace-ai-workspace.service"; +import type { + TAIWorkspaceExecutor, + TAIWorkspaceExecutorInput, + TAIWorkspaceExecutorListResponse, + TAIWorkspaceThread, + TAIWorkspaceThreadMessage, + TAIWorkspaceThreadResponse, +} from "@/services/workspace-ai-workspace.service"; + +const workspaceAIWorkspaceService = new WorkspaceAIWorkspaceService(); +const AI_CONSOLE_THREADS_WIDTH_KEY = "nodedc.ops.aiWorkspace.threadsWidth"; +const AI_CONSOLE_GEOMETRY_KEY = "nodedc.ops.aiWorkspace.consoleGeometry.v1"; +const AI_CONSOLE_VIEWPORT_MARGIN = 18; +const AI_CONSOLE_FULLSCREEN_TOP = 88; +const AI_CONSOLE_MIN_WIDTH = 960; +const AI_CONSOLE_MIN_HEIGHT = 420; +const LOCAL_THREAD_ID = "local:ops-default"; + +type TAIWorkspaceProductConsoleProps = { + open: boolean; + workspaceSlug: string; + onClose: () => void; +}; + +type TConsoleGeometry = { + x: number; + y: number; + width: number; + height: number; +}; + +type TConsoleMessage = { + id: string; + role: "system" | "user" | "assistant" | "tool"; + text: string; + createdAt?: string | null; +}; + +type TConsoleThread = { + id: string; + title: string; + meta: string; + local?: boolean; + selectedExecutorId?: string | null; + updatedAt?: string | null; +}; + +function makeId() { + try { + if (crypto && "randomUUID" in crypto) return crypto.randomUUID(); + } catch {} + return `ai-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function getConsoleViewport() { + if (typeof window === "undefined") return { width: 1280, height: 820 }; + return { + width: Math.max(320, window.innerWidth || 1280), + height: Math.max(320, window.innerHeight || 820), + }; +} + +function defaultGeometry(): TConsoleGeometry { + const viewport = getConsoleViewport(); + const width = Math.min(1080, Math.max(AI_CONSOLE_MIN_WIDTH, viewport.width - 104)); + const height = Math.min(620, Math.max(AI_CONSOLE_MIN_HEIGHT, viewport.height - 128)); + return { + x: Math.max(AI_CONSOLE_VIEWPORT_MARGIN, Math.round((viewport.width - width) / 2)), + y: Math.max(AI_CONSOLE_FULLSCREEN_TOP, Math.round((viewport.height - height) / 2)), + width, + height, + }; +} + +function normalizeGeometry(input: TConsoleGeometry): TConsoleGeometry { + const viewport = getConsoleViewport(); + const maxWidth = Math.max(AI_CONSOLE_MIN_WIDTH, viewport.width - AI_CONSOLE_VIEWPORT_MARGIN * 2); + const maxHeight = Math.max( + AI_CONSOLE_MIN_HEIGHT, + viewport.height - AI_CONSOLE_FULLSCREEN_TOP - AI_CONSOLE_VIEWPORT_MARGIN + ); + const width = Math.max(AI_CONSOLE_MIN_WIDTH, Math.min(maxWidth, Number(input.width) || AI_CONSOLE_MIN_WIDTH)); + const height = Math.max(AI_CONSOLE_MIN_HEIGHT, Math.min(maxHeight, Number(input.height) || AI_CONSOLE_MIN_HEIGHT)); + const x = Math.max( + AI_CONSOLE_VIEWPORT_MARGIN, + Math.min(viewport.width - width - AI_CONSOLE_VIEWPORT_MARGIN, Number(input.x) || 0) + ); + const y = Math.max( + AI_CONSOLE_FULLSCREEN_TOP, + Math.min(viewport.height - height - AI_CONSOLE_VIEWPORT_MARGIN, Number(input.y) || AI_CONSOLE_FULLSCREEN_TOP) + ); + return { + x: Math.round(x), + y: Math.round(y), + width: Math.round(width), + height: Math.round(height), + }; +} + +function readGeometry(): TConsoleGeometry { + if (typeof window === "undefined") return defaultGeometry(); + try { + const parsed = JSON.parse(window.localStorage?.getItem(AI_CONSOLE_GEOMETRY_KEY) || ""); + if (parsed && typeof parsed === "object") return normalizeGeometry(parsed); + } catch {} + return defaultGeometry(); +} + +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 formatMessageTime(value: string | null | undefined) { + if (!value) return ""; + const time = Date.parse(value); + if (!Number.isFinite(time)) return ""; + return new Date(time).toLocaleString("ru-RU", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function titleFromMessage(text: string) { + const normalized = text.replace(/\s+/g, " ").trim(); + if (!normalized) return "Ops задача"; + return normalized.length > 64 ? `${normalized.slice(0, 61)}...` : normalized; +} + +function threadFromApi(thread: TAIWorkspaceThread): TConsoleThread { + return { + id: thread.id, + title: thread.title || "Ops задача", + meta: thread.selectedExecutorId ? "Ops · Codex worker" : "Ops", + selectedExecutorId: thread.selectedExecutorId || null, + updatedAt: thread.updatedAt, + }; +} + +function messageFromApi(message: TAIWorkspaceThreadMessage): TConsoleMessage { + return { + id: message.id, + role: message.role || "user", + text: message.content || "", + createdAt: message.createdAt, + }; +} + +function localThread(): TConsoleThread { + return { + id: LOCAL_THREAD_ID, + title: "Ops Assistant", + meta: "Ops", + local: true, + selectedExecutorId: null, + }; +} + +function localMessages(): TConsoleMessage[] { + return [ + { + id: "local-system", + role: "system", + text: "Опиши задачу для Ops. Подключение Codex worker и устройство настраиваются через кнопку настроек в шапке.", + createdAt: new Date().toISOString(), + }, + ]; +} + +function errorText(error: unknown) { + return String((error as any)?.message || (error as any)?.error || error || "AI Workspace request failed"); +} + +function isInteractiveDragTarget(target: EventTarget | null) { + if (!(target instanceof Element)) return false; + return Boolean(target.closest("button,input,textarea,select,a,[contenteditable='true'],[role='button']")); +} + +function CloseMark() { + return