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 fb4bc04..8d8edd4 100644 --- a/plane-src/apps/api/plane/app/urls/ai_workspace.py +++ b/plane-src/apps/api/plane/app/urls/ai_workspace.py @@ -5,10 +5,14 @@ from django.urls import path from plane.app.views import ( + AIWorkspaceExecutorCheckEndpoint, AIWorkspaceExecutorDetailEndpoint, + AIWorkspaceExecutorEventsEndpoint, AIWorkspaceExecutorListEndpoint, AIWorkspaceExecutorSelectEndpoint, AIWorkspaceExecutorWindowsAgentEndpoint, + AIWorkspaceSettingsEndpoint, + AIWorkspaceThreadDispatchEndpoint, AIWorkspaceThreadDetailEndpoint, AIWorkspaceThreadListEndpoint, AIWorkspaceThreadMessagesEndpoint, @@ -16,6 +20,11 @@ from plane.app.views import ( urlpatterns = [ + path( + "workspaces//ai-workspace/settings/", + AIWorkspaceSettingsEndpoint.as_view(), + name="ai-workspace-settings", + ), path( "workspaces//ai-workspace/executors/", AIWorkspaceExecutorListEndpoint.as_view(), @@ -31,6 +40,16 @@ urlpatterns = [ AIWorkspaceExecutorSelectEndpoint.as_view(), name="ai-workspace-executor-select", ), + path( + "workspaces//ai-workspace/executors//check/", + AIWorkspaceExecutorCheckEndpoint.as_view(), + name="ai-workspace-executor-check", + ), + path( + "workspaces//ai-workspace/executors//events/", + AIWorkspaceExecutorEventsEndpoint.as_view(), + name="ai-workspace-executor-events", + ), path( "workspaces//ai-workspace/executors//agent/windows.ps1", AIWorkspaceExecutorWindowsAgentEndpoint.as_view(), @@ -51,4 +70,9 @@ urlpatterns = [ AIWorkspaceThreadMessagesEndpoint.as_view(), name="ai-workspace-thread-messages", ), + path( + "workspaces//ai-workspace/threads//dispatch/", + AIWorkspaceThreadDispatchEndpoint.as_view(), + name="ai-workspace-thread-dispatch", + ), ] diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index 6297e25..cb04c0e 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -175,10 +175,14 @@ from .module.archive import ModuleArchiveUnarchiveEndpoint from .api import ApiTokenEndpoint from .ai_workspace import ( + AIWorkspaceExecutorCheckEndpoint, AIWorkspaceExecutorDetailEndpoint, + AIWorkspaceExecutorEventsEndpoint, AIWorkspaceExecutorListEndpoint, AIWorkspaceExecutorSelectEndpoint, AIWorkspaceExecutorWindowsAgentEndpoint, + AIWorkspaceSettingsEndpoint, + AIWorkspaceThreadDispatchEndpoint, 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 e2c8d68..1061d7f 100644 --- a/plane-src/apps/api/plane/app/views/ai_workspace.py +++ b/plane-src/apps/api/plane/app/views/ai_workspace.py @@ -4,12 +4,31 @@ import os # Third party imports import requests from django.http import HttpResponse +from django.utils import timezone from rest_framework import status from rest_framework.response import Response # Module imports from plane.app.permissions import ROLE, allow_permission from plane.app.views.base import BaseAPIView +from plane.app.views.codex_agents import agent_path, get_gateway_config, owner_path, require_workspace, validate_project_in_workspace + + +AI_WORKSPACE_OPS_AGENT_DISPLAY_NAME = "AI Workspace Ops" +AI_WORKSPACE_OPS_MCP_SERVER_NAME = "nodedc_tasker" +AI_WORKSPACE_OPS_SCOPES = [ + "workspace:read", + "project:read", + "project:member:add_existing", + "issue:read", + "issue:create", + "issue:update", + "issue:move", + "issue:comment", + "issue:label", + "issue:assign", + "issue:structured_blocks:write", +] def get_ai_workspace_config(): @@ -49,12 +68,321 @@ def user_headers(user): } -def ai_workspace_request(request, method, path, payload=None, query_params=None): +def ai_workspace_gateway_json(method, path, payload=None): + base_url, token, timeout = get_gateway_config() + if not base_url or not token: + return None, Response( + { + "ok": False, + "error": "codex_agent_gateway_not_configured", + "message": "NODE.DC Codex Agent Gateway URL/token is not configured.", + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + try: + response = requests.request( + method, + f"{base_url}{path}", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + json=payload, + timeout=timeout, + ) + except requests.RequestException: + return None, Response( + { + "ok": False, + "error": "codex_agent_gateway_unavailable", + "message": "NODE.DC Codex Agent Gateway is unavailable.", + }, + status=status.HTTP_502_BAD_GATEWAY, + ) + + try: + data = response.json() + except ValueError: + data = { + "ok": False, + "error": "codex_agent_gateway_invalid_response", + "message": "NODE.DC Codex Agent Gateway returned a non-JSON response.", + } + + if response.status_code >= 400: + return None, Response(data, status=response.status_code) + return data, None + + +def get_ops_agent(request, create=True): + owner = owner_path(request.user) + agents_payload, gateway_error = ai_workspace_gateway_json("GET", f"/api/internal/v1/owners/{owner}/agents") + if gateway_error is not None: + return None, gateway_error + + agent = None + for candidate in agents_payload.get("agents") or []: + if ( + candidate.get("display_name") == AI_WORKSPACE_OPS_AGENT_DISPLAY_NAME + and candidate.get("status", "active") == "active" + ): + agent = candidate + break + + if agent is None and create: + agent_payload, gateway_error = ai_workspace_gateway_json( + "POST", + f"/api/internal/v1/owners/{owner}/agents", + { + "display_name": AI_WORKSPACE_OPS_AGENT_DISPLAY_NAME, + "owner_email": request.user.email or None, + "avatar_url": None, + }, + ) + if gateway_error is not None: + return None, gateway_error + agent = agent_payload.get("agent") or {} + + return agent, None + + +def replace_ops_agent_project_grants(request, agent_id, grants): + owner = owner_path(request.user) + _, gateway_error = ai_workspace_gateway_json( + "POST", + f"/api/internal/v1/owners/{owner}/agents/{agent_path(agent_id)}/grants/replace-projects", + { + "grants": grants, + "scopes": AI_WORKSPACE_OPS_SCOPES, + "mode": "voluntary", + }, + ) + if gateway_error is not None: + return None, gateway_error + return {"agent_id": agent_id, "grants": grants}, None + + +def ensure_ops_agent_project_grant(request, route_slug, ops_workspace_slug=None, ops_project_id=None): + ops_workspace_slug = (ops_workspace_slug or request.query_params.get("ops_workspace_slug") or route_slug or "").strip() + ops_project_id = (ops_project_id or request.query_params.get("ops_project_id") or "").strip() + if not ops_project_id: + return {}, None + + workspace, workspace_error = require_workspace(ops_workspace_slug) + if workspace_error is not None: + return None, workspace_error + + project_error = validate_project_in_workspace(workspace, ops_project_id, request.user) + if project_error is not None: + return None, project_error + + agent, gateway_error = get_ops_agent(request, create=True) + if gateway_error is not None: + return None, gateway_error + + agent_id = agent.get("id") + if not agent_id: + return None, Response( + {"ok": False, "error": "ai_workspace_ops_agent_missing"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + grant_state, gateway_error = replace_ops_agent_project_grants( + request, + agent_id, + [{"workspace_slug": ops_workspace_slug, "project_id": ops_project_id}], + ) + if gateway_error is not None: + return None, gateway_error + + return {**grant_state, "ops_workspace_slug": ops_workspace_slug, "ops_project_id": ops_project_id}, None + + +def clear_ops_agent_project_grants(request): + agent, gateway_error = get_ops_agent(request, create=False) + if gateway_error is not None: + return None, gateway_error + if not agent: + return {}, None + + agent_id = agent.get("id") + if not agent_id: + return {}, None + return replace_ops_agent_project_grants(request, agent_id, []) + + +def create_ops_mcp_token_for_request(request, agent_id): + owner = owner_path(request.user) + token_payload, gateway_error = ai_workspace_gateway_json( + "POST", + f"/api/internal/v1/owners/{owner}/agents/{agent_path(agent_id)}/tokens", + {"name": "AI Workspace Bridge"}, + ) + if gateway_error is not None: + return None, gateway_error + + mcp_server = ((token_payload.get("setup") or {}).get("mcp_server") or {}) + mcp_url = mcp_server.get("url") + mcp_token = token_payload.get("token") + if not mcp_url or not mcp_token: + return None, Response( + {"ok": False, "error": "ai_workspace_ops_mcp_setup_missing"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + return { + "opsMcpUrl": mcp_url, + "opsMcpToken": mcp_token, + "opsMcpServerName": mcp_server.get("name") or AI_WORKSPACE_OPS_MCP_SERVER_NAME, + }, None + + +def get_ai_workspace_settings_data(request): + response = ai_workspace_request(request, "GET", "/settings") + if response.status_code >= 400: + return None, response + data = getattr(response, "data", None) + if not isinstance(data, dict): + return None, Response( + {"ok": False, "error": "ai_workspace_assistant_invalid_response"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + return data, None + + +def settings_has_ops_mcp_server(settings_data): + if not isinstance(settings_data, dict): + return False + settings = settings_data.get("settings") or {} + if not isinstance(settings, dict): + return False + metadata = settings.get("metadata") or {} + if not isinstance(metadata, dict): + return False + + def is_ops_server(server): + if not isinstance(server, dict): + return False + server_name = (server.get("serverName") or server.get("server_name") or server.get("name") or "").strip() + app_id = (server.get("appId") or server.get("app_id") or "").strip().lower() + return bool(server.get("url")) and (app_id == "ops" or server_name == AI_WORKSPACE_OPS_MCP_SERVER_NAME) + + def has_ops_server(value): + if isinstance(value, list): + return any(is_ops_server(item) for item in value) + if isinstance(value, dict): + return any(is_ops_server(item) for item in value.values()) + return False + + app_grants = metadata.get("appGrants") or {} + ops_grant = app_grants.get("ops") if isinstance(app_grants, dict) else {} + if isinstance(ops_grant, dict) and has_ops_server(ops_grant.get("mcpServers")): + return True + return has_ops_server(metadata.get("mcpServers")) + + +def build_ops_app_grant(active_context, ops_mcp_params=None): + context = active_context if isinstance(active_context, dict) else {} + grant = { + "appId": "ops", + "appTitle": "NODE.DC Ops", + "surface": "ops", + "updatedAt": timezone.now().isoformat(), + "context": { + "opsWorkspaceSlug": context.get("opsWorkspaceSlug") or context.get("workspaceSlug") or "", + "opsRouteWorkspaceSlug": context.get("opsRouteWorkspaceSlug") or "", + "opsProjectId": context.get("opsProjectId") or "", + "opsProjectIdentifier": context.get("opsProjectIdentifier") or "", + "opsProjectSlug": context.get("opsProjectSlug") or "", + "opsProjectTitle": context.get("opsProjectTitle") or "", + }, + } + if not ops_mcp_params: + return grant + mcp_url = ops_mcp_params.get("opsMcpUrl") + mcp_token = ops_mcp_params.get("opsMcpToken") + if not mcp_url or not mcp_token: + return grant + grant["mcpServers"] = [ + { + "serverName": ops_mcp_params.get("opsMcpServerName") or AI_WORKSPACE_OPS_MCP_SERVER_NAME, + "url": mcp_url, + "enabled": True, + "required": False, + "startupTimeoutSec": 20, + "toolTimeoutSec": 60, + "httpHeaders": { + "Authorization": f"Bearer {mcp_token}", + "Accept": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + } + ] + return grant + + +def attach_ops_grant_for_active_context(request, slug, payload, settings_data=None): + active_context = payload.get("activeContext") or {} + if not isinstance(active_context, dict): + active_context = {} + + ops_project_id = (active_context.get("opsProjectId") or "").strip() + if settings_data is None: + settings_data, settings_error = get_ai_workspace_settings_data(request) + if settings_error is not None: + return None, settings_error + + if not ops_project_id: + if settings_has_ops_mcp_server(settings_data): + _, clear_error = clear_ops_agent_project_grants(request) + if clear_error is not None: + return None, clear_error + return attach_ops_app_grant(payload, build_ops_app_grant(active_context)), None + + grant_state, grant_error = ensure_ops_agent_project_grant( + request, + slug, + ops_workspace_slug=active_context.get("opsWorkspaceSlug") or active_context.get("workspaceSlug") or slug, + ops_project_id=ops_project_id, + ) + if grant_error is not None: + return None, grant_error + + ops_mcp_params = None + agent_id = (grant_state or {}).get("agent_id") + if agent_id and not settings_has_ops_mcp_server(settings_data): + ops_mcp_params, ops_mcp_error = create_ops_mcp_token_for_request(request, agent_id) + if ops_mcp_error is not None: + return None, ops_mcp_error + + return attach_ops_app_grant(payload, build_ops_app_grant(active_context, ops_mcp_params)), None + + +def attach_ops_app_grant(payload, app_grant): + if not app_grant: + return payload + metadata = payload.get("metadata") or {} + if not isinstance(metadata, dict): + metadata = {} + app_grants = metadata.get("appGrants") or {} + if not isinstance(app_grants, dict): + app_grants = {} + metadata["appGrants"] = { + **app_grants, + "ops": app_grant, + } + payload["metadata"] = metadata + return payload + + +def ai_workspace_request(request, method, path, payload=None, query_params=None, timeout_override=None): config, error_response = require_ai_workspace_config() if error_response is not None: return error_response base_url, token, timeout = config + request_timeout = timeout_override or timeout try: response = requests.request( method, @@ -66,7 +394,7 @@ def ai_workspace_request(request, method, path, payload=None, query_params=None) }, params=query_params, json=payload, - timeout=timeout, + timeout=request_timeout, ) except requests.RequestException: return Response( @@ -95,10 +423,30 @@ def ops_thread_payload(raw_payload, slug): active_context = payload.get("activeContext") or payload.get("active_context") or {} if not isinstance(active_context, dict): active_context = {} + ops_workspace_slug = active_context.get("opsWorkspaceSlug") or active_context.get("workspaceSlug") or slug active_context = { **active_context, "surface": "ops", - "workspaceSlug": slug, + "workspaceSlug": ops_workspace_slug, + "opsWorkspaceSlug": ops_workspace_slug, + "opsRouteWorkspaceSlug": slug, + } + contexts = active_context.get("contexts") + if not isinstance(contexts, dict): + contexts = {} + ops_context = contexts.get("ops") + if not isinstance(ops_context, dict): + ops_context = {} + active_context["contexts"] = { + **contexts, + "ops": { + **ops_context, + **active_context, + "surface": "ops", + "workspaceSlug": ops_workspace_slug, + "opsWorkspaceSlug": ops_workspace_slug, + "opsRouteWorkspaceSlug": slug, + }, } tool_packs = payload.get("enabledToolPacks") or payload.get("enabled_tool_packs") or [] @@ -113,6 +461,64 @@ def ops_thread_payload(raw_payload, slug): return payload +def ops_settings_payload(raw_payload, slug): + payload = dict(raw_payload or {}) + active_context = payload.get("activeContext") or payload.get("active_context") or {} + if not isinstance(active_context, dict): + active_context = {} + ops_workspace_slug = active_context.get("opsWorkspaceSlug") or active_context.get("workspaceSlug") or slug + active_context = { + **active_context, + "surface": "ops", + "workspaceSlug": ops_workspace_slug, + "opsWorkspaceSlug": ops_workspace_slug, + "opsRouteWorkspaceSlug": slug, + } + + contexts = active_context.get("contexts") + if not isinstance(contexts, dict): + contexts = {} + ops_context = contexts.get("ops") + if not isinstance(ops_context, dict): + ops_context = {} + active_context["contexts"] = { + **contexts, + "ops": { + **ops_context, + **active_context, + "surface": "ops", + "workspaceSlug": ops_workspace_slug, + "opsWorkspaceSlug": ops_workspace_slug, + "opsRouteWorkspaceSlug": slug, + }, + } + + tool_packs = payload.get("enabledToolPacks") or payload.get("enabled_tool_packs") or [] + if not isinstance(tool_packs, list): + tool_packs = [] + for tool_pack in ("ops", "engine"): + if tool_pack not in tool_packs: + tool_packs.append(tool_pack) + + payload["activeContext"] = active_context + payload["enabledToolPacks"] = tool_packs + return payload + + +class AIWorkspaceSettingsEndpoint(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def get(self, request, slug): + return ai_workspace_request(request, "GET", "/settings") + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def patch(self, request, slug): + payload = ops_settings_payload(request.data, slug) + payload, ops_grant_error = attach_ops_grant_for_active_context(request, slug, payload) + if ops_grant_error is not None: + return ops_grant_error + return ai_workspace_request(request, "PATCH", "/settings", payload) + + class AIWorkspaceExecutorListEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug): @@ -139,6 +545,34 @@ class AIWorkspaceExecutorSelectEndpoint(BaseAPIView): return ai_workspace_request(request, "POST", f"/executors/{executor_id}/select") +class AIWorkspaceExecutorCheckEndpoint(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def post(self, request, slug, executor_id): + return ai_workspace_request( + request, + "POST", + f"/executors/{executor_id}/check", + request.data, + timeout_override=15, + ) + + +class AIWorkspaceExecutorEventsEndpoint(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def get(self, request, slug, executor_id): + query_params = { + "since": request.query_params.get("since") or "0", + "limit": request.query_params.get("limit") or "100", + } + return ai_workspace_request( + request, + "GET", + f"/executors/{executor_id}/events", + query_params=query_params, + timeout_override=15, + ) + + class AIWorkspaceExecutorWindowsAgentEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug, executor_id): @@ -150,6 +584,34 @@ class AIWorkspaceExecutorWindowsAgentEndpoint(BaseAPIView): params = {} if request.query_params.get("port"): params["port"] = request.query_params.get("port") + ops_project_id = (request.query_params.get("ops_project_id") or "").strip() + if ops_project_id: + ops_workspace_slug = (request.query_params.get("ops_workspace_slug") or slug or "").strip() + active_context = { + "surface": "ops", + "workspaceSlug": ops_workspace_slug, + "opsWorkspaceSlug": ops_workspace_slug, + "opsRouteWorkspaceSlug": slug, + "opsProjectId": ops_project_id, + } + sync_payload = ops_settings_payload( + { + "activeContext": active_context, + "enabledToolPacks": ["ops", "engine"], + "metadata": { + "source": "ops-ai-workspace-installer", + "updatedAt": timezone.now().isoformat(), + }, + }, + slug, + ) + sync_payload, ops_grant_error = attach_ops_grant_for_active_context(request, slug, sync_payload) + if ops_grant_error is not None: + return ops_grant_error + sync_response = ai_workspace_request(request, "PATCH", "/settings", sync_payload) + if sync_response.status_code >= 400: + return sync_response + try: response = requests.get( f"{base_url}/api/ai-workspace/assistant/v1/executors/{executor_id}/agent/windows.ps1", @@ -185,9 +647,11 @@ class AIWorkspaceThreadListEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug): query_params = { - "surface": request.query_params.get("surface") or "ops", + "kind": request.query_params.get("kind") or "shared", "limit": request.query_params.get("limit") or "100", } + if request.query_params.get("surface"): + query_params["surface"] = request.query_params.get("surface") return ai_workspace_request(request, "GET", "/threads", query_params=query_params) @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") @@ -218,3 +682,27 @@ class AIWorkspaceThreadMessagesEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug, thread_id): return ai_workspace_request(request, "POST", f"/threads/{thread_id}/messages", request.data) + + +class AIWorkspaceThreadDispatchEndpoint(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def post(self, request, slug, thread_id): + payload = dict(request.data or {}) + context = payload.get("context") or {} + if not isinstance(context, dict): + context = {} + ops_workspace_slug = context.get("opsWorkspaceSlug") or context.get("workspaceSlug") or slug + payload["context"] = { + **context, + "surface": "ops", + "workspaceSlug": ops_workspace_slug, + "opsWorkspaceSlug": ops_workspace_slug, + "opsRouteWorkspaceSlug": slug, + } + return ai_workspace_request( + request, + "POST", + f"/threads/{thread_id}/dispatch", + payload, + timeout_override=20, + ) 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 index b325de6..2260ad5 100644 --- a/plane-src/apps/web/core/components/ai-workspace/product-console.tsx +++ b/plane-src/apps/web/core/components/ai-workspace/product-console.tsx @@ -4,17 +4,20 @@ * See the LICENSE file for details. */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { CSSProperties, FormEvent, PointerEvent as ReactPointerEvent } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { CSSProperties, FormEvent, PointerEvent as ReactPointerEvent, ReactNode } 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 { + TAIWorkspaceBridgeEvent, TAIWorkspaceExecutor, TAIWorkspaceExecutorInput, TAIWorkspaceExecutorListResponse, + TAIWorkspaceOpsProject, + TAIWorkspaceOpsWorkspace, TAIWorkspaceThread, TAIWorkspaceThreadMessage, TAIWorkspaceThreadResponse, @@ -23,11 +26,14 @@ import type { 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_PROJECT_KEY_PREFIX = "nodedc.ops.aiWorkspace.project."; +const AI_CONSOLE_WORKSPACE_KEY = "nodedc.ops.aiWorkspace.workspace"; 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"; +const MAX_BRIDGE_EVENTS = 500; type TAIWorkspaceProductConsoleProps = { open: boolean; @@ -46,6 +52,7 @@ type TConsoleMessage = { id: string; role: "system" | "user" | "assistant" | "tool"; text: string; + payload?: Record; createdAt?: string | null; }; @@ -55,9 +62,26 @@ type TConsoleThread = { meta: string; local?: boolean; selectedExecutorId?: string | null; + running?: boolean; + status?: "idle" | "running" | "completed" | "failed" | "timeout"; updatedAt?: string | null; }; +type TBridgeActivityRun = { + requestId: string; + command: string; + status: "running" | "completed" | "failed" | "timeout"; + events: TAIWorkspaceBridgeEvent[]; + startedAt: string; + updatedAt: string; +}; + +type TConsoleProcessItem = { + id: string; + kind: string; + text: string; +}; + function makeId() { try { if (crypto && "randomUUID" in crypto) return crypto.randomUUID(); @@ -145,25 +169,78 @@ function titleFromMessage(text: string) { return normalized.length > 64 ? `${normalized.slice(0, 61)}...` : normalized; } +function publicUserMessageText(value: string) { + const text = String(value || "").trim(); + if (!/^System context guard:/i.test(text)) return text; + const match = text.match(/\nUser message:\s*([\s\S]*)$/i); + return (match?.[1] || text).trim(); +} + +function visibleConsoleMessages(messages: TConsoleMessage[] = []) { + return messages; +} + function threadFromApi(thread: TAIWorkspaceThread): TConsoleThread { + const activeContext = thread.activeContext && typeof thread.activeContext === "object" ? thread.activeContext : {}; + const opsContext = + activeContext.contexts && typeof activeContext.contexts === "object" + ? (activeContext.contexts as Record).ops || {} + : {}; + const workspaceTitle = String( + activeContext.opsWorkspaceTitle || + opsContext.opsWorkspaceTitle || + activeContext.opsWorkspaceSlug || + opsContext.opsWorkspaceSlug || + "" + ).trim(); + const projectTitle = String( + activeContext.opsProjectTitle || + opsContext.opsProjectTitle || + activeContext.opsProjectIdentifier || + opsContext.opsProjectIdentifier || + "" + ).trim(); return { id: thread.id, title: thread.title || "Ops задача", - meta: thread.selectedExecutorId ? "Ops · Codex worker" : "Ops", + meta: ["Ops", workspaceTitle || "", projectTitle || "", thread.selectedExecutorId ? "Codex worker" : ""] + .filter(Boolean) + .join(" · "), selectedExecutorId: thread.selectedExecutorId || null, updatedAt: thread.updatedAt, }; } function messageFromApi(message: TAIWorkspaceThreadMessage): TConsoleMessage { + const role = message.role || "user"; + const rawText = message.content || ""; return { id: message.id, - role: message.role || "user", - text: message.content || "", + role, + text: role === "user" ? publicUserMessageText(rawText) : rawText, + payload: message.payload && typeof message.payload === "object" ? message.payload : undefined, createdAt: message.createdAt, }; } +function bridgeResultRequestIdFromMessage(message: TConsoleMessage) { + const payload = message.payload && typeof message.payload === "object" ? message.payload : {}; + const kind = String(payload.kind || ""); + if (message.role !== "assistant" || (kind !== "bridge_final" && kind !== "bridge_failure")) return ""; + return String(payload.requestId || payload.request_id || "").trim(); +} + +function dedupeConsoleMessages(messages: TConsoleMessage[]) { + const seenBridgeResultRequests = new Set(); + return messages.filter((message) => { + const requestId = bridgeResultRequestIdFromMessage(message); + if (!requestId) return true; + if (seenBridgeResultRequests.has(requestId)) return false; + seenBridgeResultRequests.add(requestId); + return true; + }); +} + function localThread(): TConsoleThread { return { id: LOCAL_THREAD_ID, @@ -189,6 +266,502 @@ function errorText(error: unknown) { return String((error as any)?.message || (error as any)?.error || error || "AI Workspace request failed"); } +function projectLabel(project: TAIWorkspaceOpsProject | null | undefined) { + if (!project) return ""; + return String( + project.title || project.name || project.identifier || project.projectIdentifier || project.id || "" + ).trim(); +} + +function projectIdentifier(project: TAIWorkspaceOpsProject | null | undefined) { + return String(project?.identifier || project?.projectIdentifier || "").trim(); +} + +function projectSlug(project: TAIWorkspaceOpsProject | null | undefined) { + return String(project?.slug || project?.name || "").trim(); +} + +function workspaceLabel(workspace: TAIWorkspaceOpsWorkspace | null | undefined) { + if (!workspace) return ""; + return String(workspace.title || workspace.name || workspace.slug || workspace.id || "").trim(); +} + +function redactAiWorkspacePublicText(text: string) { + return String(text || "") + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, "Bearer [redacted]") + .replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, "[redacted-token]") + .replace(/\b((?:api[_-]?key|token|secret|password)\s*[:=]\s*)[^\s,;'"`]+/gi, "$1[redacted]"); +} + +function sanitizeBridgeErrorText(value: string, max = 1400) { + const text = redactAiWorkspacePublicText(String(value || "").replace(/\r\n/g, "\n")).trim(); + if (!text) return ""; + const statusMatch = text.match( + /\b(?:unexpected status|HTTP error:)?\s*((?:401|403|429|5\d\d)\s+[A-Za-z][A-Za-z\s-]*)\b/i + ); + const urlMatch = + text.match(/\burl:\s*((?:https?|wss):\/\/[^\s,]+)/i) || text.match(/\b((?:https?|wss):\/\/chatgpt\.com\/[^\s,]+)/i); + const rayMatch = text.match(/\bcf-ray:\s*([A-Za-z0-9-]+)/i) || text.match(/\bRay ID:?\s*([A-Za-z0-9-]+)/i); + if (/chatgpt\.com\/backend-api\/codex|Unable to load site|cf-ray|Cloudflare|403 Forbidden/i.test(text)) { + return [ + `Codex backend rejected request${statusMatch ? `: ${statusMatch[1].trim()}` : ""}.`, + urlMatch ? `url: ${urlMatch[1].replace(/[)>]+$/, "")}` : "", + rayMatch ? `cf-ray: ${rayMatch[1]}` : "", + ] + .filter(Boolean) + .join(" "); + } + return text.length > max ? `${text.slice(0, max).trim()}...` : text; +} + +function renderInlineText(text: string) { + const parts = String(text || "").split(/(`[^`]+`)/g); + return parts.map((part, index) => { + if (part.startsWith("`") && part.endsWith("`") && part.length > 1) { + return {part.slice(1, -1)}; + } + return {part}; + }); +} + +function renderRichMessage(text: string): ReactNode { + const lines = redactAiWorkspacePublicText(String(text || "")) + .replace(/\r\n/g, "\n") + .split("\n"); + const nodes: ReactNode[] = []; + let paragraph: string[] = []; + let list: Array<{ ordered: boolean; text: string }> = []; + let codeLines: string[] = []; + let codeLang = ""; + let inCode = false; + + const flushParagraph = () => { + if (!paragraph.length) return; + nodes.push(

{renderInlineText(paragraph.join("\n"))}

); + paragraph = []; + }; + + const flushList = () => { + if (!list.length) return; + const ordered = list[0]?.ordered; + const items = list.map((item, index) =>
  • {renderInlineText(item.text)}
  • ); + nodes.push(ordered ?
      {items}
    :
      {items}
    ); + list = []; + }; + + const flushCode = () => { + nodes.push( +
    +        {codeLines.join("\n")}
    +      
    + ); + codeLines = []; + codeLang = ""; + }; + + lines.forEach((line) => { + const fence = line.match(/^\s*```([A-Za-z0-9_-]+)?\s*$/); + if (fence) { + if (inCode) { + flushCode(); + inCode = false; + } else { + flushParagraph(); + flushList(); + inCode = true; + codeLang = fence[1] || ""; + } + return; + } + if (inCode) { + codeLines.push(line); + return; + } + if (!line.trim()) { + flushParagraph(); + flushList(); + return; + } + const heading = line.match(/^\s{0,3}(#{1,4})\s+(.+)$/); + if (heading) { + flushParagraph(); + flushList(); + nodes.push(

    {renderInlineText(heading[2].trim())}

    ); + return; + } + const bullet = line.match(/^\s*[-*•]\s+(.+)$/); + if (bullet) { + flushParagraph(); + list.push({ ordered: false, text: bullet[1].trim() }); + return; + } + const numbered = line.match(/^\s*\d+[.)]\s+(.+)$/); + if (numbered) { + flushParagraph(); + list.push({ ordered: true, text: numbered[1].trim() }); + return; + } + flushList(); + paragraph.push(line); + }); + + if (inCode) flushCode(); + flushParagraph(); + flushList(); + return nodes.length ? nodes :

    {renderInlineText(text)}

    ; +} + +function truncateActivityText(text: string, limit = 420) { + const compact = redactAiWorkspacePublicText(text).replace(/\r\n/g, "\n").trim(); + return compact.length > limit ? `${compact.slice(0, limit).trim()}...` : compact; +} + +function normalizeMessageText(text: string) { + return String(text || "") + .replace(/\s+/g, " ") + .trim(); +} + +function valueFromCodexHeader(text: string, key: string) { + const match = text.match(new RegExp(`^${key}:\\s*(.+)$`, "im")); + return match?.[1]?.trim() || ""; +} + +function summarizeLegacyCodexOutput(text: string) { + const value = redactAiWorkspacePublicText(text).replace(/\r\n/g, "\n").trim(); + if (!value) return ""; + const sanitized = sanitizeBridgeErrorText(value); + if (sanitized && sanitized !== value && /^Codex backend rejected request/i.test(sanitized)) return sanitized; + if (/^user\s*\nYou are connected to NODE\.DC Engine through AI Workspace Bridge\./i.test(value)) { + return "Prompt delivered to Codex."; + } + if ( + value.includes("You are connected to NODE.DC Engine through AI Workspace Bridge.") || + value.includes("Recent conversation from Engine:") + ) { + return "Prompt delivered to Codex."; + } + if (/failed to refresh available models/i.test(value)) { + return "Codex warning: model list refresh timed out, execution continues."; + } + if (/^OpenAI Codex v/i.test(value)) return "Codex process initialized."; + if (/^workdir:/im.test(value) && /^model:/im.test(value)) { + const parts = [ + valueFromCodexHeader(value, "workdir"), + valueFromCodexHeader(value, "model") ? `model ${valueFromCodexHeader(value, "model")}` : "", + valueFromCodexHeader(value, "sandbox") ? `sandbox ${valueFromCodexHeader(value, "sandbox")}` : "", + valueFromCodexHeader(value, "reasoning effort") + ? `reasoning ${valueFromCodexHeader(value, "reasoning effort")}` + : "", + ].filter(Boolean); + return parts.length ? `Codex session: ${parts.join(" · ")}` : "Codex session initialized."; + } + if (/^session id:/im.test(value)) { + return `Codex ${valueFromCodexHeader(value, "session id") || "session"} attached.`; + } + if (/^tokens used/im.test(value)) { + const tokens = value.replace(/^tokens used/i, "").trim(); + return tokens ? `Tokens used: ${tokens}` : "Tokens reported."; + } + if (/^codex\s*\n/i.test(value)) { + const answer = value.replace(/^codex\s*\n/i, "").trim(); + return answer + ? `Codex emitted assistant output: ${truncateActivityText(answer, 260)}` + : "Codex emitted assistant output."; + } + return truncateActivityText(value); +} + +function safeBridgeEvent(input: TAIWorkspaceBridgeEvent | null | undefined): TAIWorkspaceBridgeEvent | null { + if (!input || typeof input !== "object") return null; + const kind = String(input.kind || "").trim(); + if (!kind) return null; + const rawId = Number(input.id); + return { + ...input, + id: Number.isFinite(rawId) ? rawId : 0, + kind, + }; +} + +function safeBridgeEvents(events: TAIWorkspaceBridgeEvent[] | null | undefined): TAIWorkspaceBridgeEvent[] { + if (!Array.isArray(events)) return []; + return events + .map((event) => safeBridgeEvent(event)) + .filter((event): event is TAIWorkspaceBridgeEvent => Boolean(event)); +} + +function bridgeRunEvents(run: TBridgeActivityRun | null | undefined): TAIWorkspaceBridgeEvent[] { + return safeBridgeEvents(run?.events); +} + +function eventTimeMs(event: TAIWorkspaceBridgeEvent | null | undefined) { + const safeEvent = safeBridgeEvent(event); + if (!safeEvent) return 0; + const value = Date.parse(String(safeEvent.at || "")); + return Number.isFinite(value) ? value : 0; +} + +function formatBridgeEvent(input: TAIWorkspaceBridgeEvent | null | undefined) { + const event = safeBridgeEvent(input); + if (!event) return ""; + const text = String(event.text || event.message || event.command || "").trim(); + let out = ""; + if (event.kind === "request") out = event.command ? `request: ${event.command}` : "request sent"; + else if (event.kind === "engine_send") + out = event.message || (event.command ? `engine sent: ${event.command}` : "engine sent request"); + else if (event.kind === "bridge_received") out = text || "bridge received request"; + else if (event.kind === "start") out = event.command ? `$ ${event.command}` : "codex process started"; + else if (event.kind === "first_output") out = text || "first codex output"; + else if (event.kind === "response_received") out = text || "bridge response received"; + else if (event.kind === "codex_thread") out = text || "codex thread started"; + else if (event.kind === "codex_turn") out = text || "codex turn started"; + else if (event.kind === "codex_turn_completed") out = text || "codex turn completed"; + else if (event.kind === "codex_reasoning") out = text || "codex reasoning"; + else if (event.kind === "codex_command") + out = [text, event.command ? `$ ${event.command}` : ""].filter(Boolean).join("\n"); + else if (event.kind === "codex_files") out = text || "file changes reported"; + else if (event.kind === "codex_tool") out = text || "tool call"; + else if (event.kind === "codex_todo") out = text || "todo update"; + else if (event.kind === "codex_message") out = text || "assistant response received"; + else if (event.kind === "codex_event") out = text || "codex event"; + else if (event.kind === "stdout" || event.kind === "stderr") out = summarizeLegacyCodexOutput(text); + else if (event.kind === "done") out = text || "done"; + else if (event.kind === "stopped") out = text || "stopped"; + else if (event.kind === "error") out = text || "error"; + else if (event.kind === "timeout") out = text || "timeout"; + else if (event.kind === "fallback") out = text || "fallback"; + else if (event.kind === "connected") out = text || "agent connected"; + else if (event.kind === "disconnected") out = text || "agent disconnected"; + else out = text || String(event.kind || "event"); + return sanitizeBridgeErrorText(out); +} + +function bridgeEventDisplayKind(input: TAIWorkspaceBridgeEvent | null | undefined) { + const event = safeBridgeEvent(input); + if (!event) return ""; + const kind = String(event.kind || ""); + if (kind !== "stdout" && kind !== "stderr") return kind; + const text = String(event.text || event.message || "").trim(); + const summary = summarizeLegacyCodexOutput(text); + if ( + summary === "Prompt delivered to Codex." || + summary === "Codex process initialized." || + summary === "Codex session initialized." || + summary.startsWith("Codex session:") || + summary.startsWith("Codex warning:") || + (summary.startsWith("Codex ") && summary.endsWith(" attached.")) || + summary.startsWith("Tokens used:") + ) { + return "codex_event"; + } + if (summary.startsWith("Codex emitted assistant output:")) return "codex_message"; + return kind; +} + +function isTechnicalProcessText(text: string) { + return /\b(powershell|windowspowershell|invoke-webrequest|get-nettcpconnection|tasklist|netstat|cmd\.exe|curl|localhost|127\.0\.0\.1|0\.0\.0\.0|api\/ai-workspace|bridge\/v1|requestId|thread\/resume|codex_core::session|MCP server|EADDRINUSE)\b/i.test( + text + ); +} + +function cleanActivityPath(value: string) { + const compact = value + .replace(/^[`'"]+|[`'"]+$/g, "") + .replace(/\s+$/g, "") + .trim(); + return ( + compact + .split(/[\\/]/) + .filter(Boolean) + .pop() + ?.replace(/^[`'"]+|[`'"]+$/g, "") || compact + ); +} + +function commandFileLabel(command: string) { + const readMatch = + command.match(/\b(?:Get-Content|gc|type|cat|nl)\s+["'`]?([^"'`\r\n|;&]+)["'`]?/i) || + command.match(/\bsed\s+-n\s+["'][^"']+["']\s+([^|;&\r\n]+)/i); + return readMatch ? cleanActivityPath(readMatch[1]) : ""; +} + +function publicCommandActivity(event: TAIWorkspaceBridgeEvent) { + const command = String(event.command || event.message || "").trim(); + if (!command) return ""; + const fileLabel = commandFileLabel(command); + if (fileLabel) return `Читает \`${fileLabel}\`.`; + if (/\b(rg|ripgrep|findstr)\b|Select-String/i.test(command)) return "Ищет по проекту."; + if (/\bgit\s+diff\b/i.test(command)) return "Смотрит diff изменений."; + if (/\bgit\s+status\b/i.test(command)) return "Проверяет рабочее дерево."; + if (/\b(?:npm|pnpm|yarn)\s+(?:run\s+)?(?:build|test|lint|typecheck)\b/i.test(command)) { + return "Запускает проверку проекта."; + } + if (isTechnicalProcessText(command)) return ""; + return ""; +} + +function cleanPublicSummaryMarkdown(text: string) { + return text.replace(/^\s*\*\*([^*\n]{1,100})\*\*\s*/gm, "$1. ").replace(/\*\*([^*\n]{1,100})\*\*/g, "$1"); +} + +function publicReasoningSummary(text: string) { + const compact = truncateActivityText(cleanPublicSummaryMarkdown(text), 900); + if (!compact || isTechnicalProcessText(compact)) return ""; + return compact; +} + +function hasCyrillicText(text: string) { + return /[А-Яа-яЁё]/.test(text); +} + +function codexProcessTextFromEvent(event: TAIWorkspaceBridgeEvent | null | undefined) { + const kind = bridgeEventDisplayKind(event); + const text = formatBridgeEvent(event); + if (!text || text === "Prompt delivered to Codex.") return ""; + if (kind === "codex_message") return publicReasoningSummary(text.replace(/^Codex emitted assistant output:\s*/i, "")); + if (kind === "codex_reasoning") return publicReasoningSummary(text); + if (kind === "codex_todo") return publicReasoningSummary(text); + if (kind === "codex_command") return publicCommandActivity(event as TAIWorkspaceBridgeEvent); + if (kind === "codex_files") return "Проверяет изменения файлов."; + return ""; +} + +function assistantTextFromBridgeEvent(event: TAIWorkspaceBridgeEvent | null | undefined) { + const safeEvent = safeBridgeEvent(event); + if (!safeEvent) return ""; + if (bridgeEventDisplayKind(safeEvent) !== "codex_message") return ""; + if (safeEvent.kind === "codex_message") { + return redactAiWorkspacePublicText( + String(safeEvent.text || "") + .trim() + .replace(/^Codex emitted assistant output:\s*/i, "") + ); + } + const text = formatBridgeEvent(safeEvent); + return redactAiWorkspacePublicText(text.replace(/^Codex emitted assistant output:\s*/i, "").trim()); +} + +function buildCodexProcessItems(run: TBridgeActivityRun | null | undefined): TConsoleProcessItem[] { + if (!run) return []; + const seen = new Set(); + const rawItems = bridgeRunEvents(run).map((event) => ({ + id: String(event.id), + kind: bridgeEventDisplayKind(event), + text: codexProcessTextFromEvent(event), + })); + const hasUserFacingProgress = rawItems.some((item) => item.kind === "codex_message" && item.text); + return rawItems + .filter((item) => { + if (!item.text || seen.has(item.text)) return false; + if (hasUserFacingProgress && item.kind === "codex_reasoning" && !hasCyrillicText(item.text)) return false; + seen.add(item.text); + return true; + }) + .slice(-6); +} + +function isBridgeReconnectError(event: TAIWorkspaceBridgeEvent | null | undefined) { + const safeEvent = safeBridgeEvent(event); + if (!safeEvent) return false; + return /reconnecting/i.test(`${safeEvent.message || ""} ${safeEvent.text || ""}`); +} + +function isCodexExitNullError(event: TAIWorkspaceBridgeEvent | null | undefined) { + const safeEvent = safeBridgeEvent(event); + if (!safeEvent) return false; + return /\bcodex_exit_null\b/i.test(`${safeEvent.message || ""} ${safeEvent.text || ""}`); +} + +function bridgeRunStatus(events: TAIWorkspaceBridgeEvent[]): TBridgeActivityRun["status"] { + const safeEvents = safeBridgeEvents(events); + if (safeEvents.some((event) => event.kind === "timeout")) return "timeout"; + if (safeEvents.some((event) => event.kind === "stopped")) return "completed"; + const completed = safeEvents.some((event) => event.kind === "done" || event.kind === "codex_turn_completed"); + const fatalError = safeEvents.some( + (event) => event.kind === "error" && !isBridgeReconnectError(event) && !(completed && isCodexExitNullError(event)) + ); + if (fatalError) return "failed"; + if (completed) return "completed"; + return "running"; +} + +function bridgeRunWasStopped(run: TBridgeActivityRun | null | undefined) { + return bridgeRunEvents(run).some((event) => event.kind === "stopped"); +} + +function formatRunDuration(run: TBridgeActivityRun | null | undefined, forceRunning = false) { + if (!run) return ""; + const started = Date.parse(run.startedAt || ""); + const updated = run.status === "running" || forceRunning ? Date.now() : Date.parse(run.updatedAt || ""); + if (!Number.isFinite(started) || !Number.isFinite(updated)) return ""; + const seconds = Math.max(0, Math.round((updated - started) / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + if (minutes < 60) return `${minutes}m ${rest}s`; + const hours = Math.floor(minutes / 60); + const minuteRest = minutes % 60; + return `${hours}h ${minuteRest}m`; +} + +function buildBridgeActivityRuns(events: TAIWorkspaceBridgeEvent[]): TBridgeActivityRun[] { + const byRequest = new Map(); + safeBridgeEvents(events).forEach((event) => { + const requestId = String(event.requestId || "").trim(); + if (!requestId) return; + const list = byRequest.get(requestId) || []; + list.push(event); + byRequest.set(requestId, list); + }); + return Array.from(byRequest.entries()) + .map(([requestId, list]) => { + const sorted = [...list].sort((a, b) => Number(a.id || 0) - Number(b.id || 0)); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + const commandEvent = sorted.find((event) => event.command) || first; + return { + requestId, + command: String(commandEvent?.command || "").trim(), + status: bridgeRunStatus(sorted), + events: sorted, + startedAt: first?.at || "", + updatedAt: last?.at || "", + }; + }) + .sort((a, b) => eventTimeMs(b.events[b.events.length - 1]) - eventTimeMs(a.events[a.events.length - 1])); +} + +function bridgeRunThreadId(run: TBridgeActivityRun | null | undefined) { + return ( + bridgeRunEvents(run) + .find((event) => String(event.threadId || "").trim()) + ?.threadId?.trim() || "" + ); +} + +function bridgeRunAssistantEvent(run: TBridgeActivityRun | null | undefined) { + if (!run || run.status === "running") return undefined; + return [...bridgeRunEvents(run)].reverse().find((event) => assistantTextFromBridgeEvent(event)); +} + +function formatBridgeLogLine(event: TAIWorkspaceBridgeEvent) { + const kind = bridgeEventDisplayKind(event); + const text = formatBridgeEvent(event); + return [kind, text].filter(Boolean).join("\n"); +} + +function bridgeFailureMessage(run: TBridgeActivityRun) { + const latest = [...bridgeRunEvents(run)] + .reverse() + .find((event) => event.kind === "error" || event.kind === "timeout"); + const text = latest ? formatBridgeEvent(latest) : ""; + if (run.status === "timeout") return `Bridge timeout${text ? `: ${text}` : ""}`; + if (run.status === "failed") return `Bridge failed${text ? `: ${text}` : ""}`; + if (run.status === "completed") return "Bridge завершил задачу без текстового ответа."; + return "Bridge выполняет задачу."; +} + function isInteractiveDragTarget(target: EventTarget | null) { if (!(target instanceof Element)) return false; return Boolean(target.closest("button,input,textarea,select,a,[contenteditable='true'],[role='button']")); @@ -282,6 +855,25 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW }); const [executors, setExecutors] = useState([]); const [selectedExecutorId, setSelectedExecutorId] = useState(null); + const [workspaceOptions, setWorkspaceOptions] = useState([]); + const [selectedOpsWorkspaceSlug, setSelectedOpsWorkspaceSlug] = useState(() => { + if (typeof window === "undefined") return workspaceSlug; + try { + return window.localStorage?.getItem(AI_CONSOLE_WORKSPACE_KEY) || workspaceSlug; + } catch { + return workspaceSlug; + } + }); + const [projectOptions, setProjectOptions] = useState([]); + const [selectedProjectId, setSelectedProjectId] = useState(() => { + if (typeof window === "undefined") return ""; + try { + const opsWorkspaceSlug = window.localStorage?.getItem(AI_CONSOLE_WORKSPACE_KEY) || workspaceSlug; + return window.localStorage?.getItem(`${AI_CONSOLE_PROJECT_KEY_PREFIX}${opsWorkspaceSlug}`) || ""; + } catch { + return ""; + } + }); const [threads, setThreads] = useState(() => [localThread()]); const [activeThreadId, setActiveThreadId] = useState(LOCAL_THREAD_ID); const [messagesByThread, setMessagesByThread] = useState>(() => ({ @@ -293,22 +885,103 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW const [actionError, setActionError] = useState(""); const [busyId, setBusyId] = useState(""); const [threadError, setThreadError] = useState(""); + const [workspaceLoadError, setWorkspaceLoadError] = useState(""); + const [projectLoadError, setProjectLoadError] = useState(""); const [sending, setSending] = useState(false); + const [bridgeEvents, setBridgeEvents] = useState([]); + const [bridgeClockTick, setBridgeClockTick] = useState(0); const [editingThreadId, setEditingThreadId] = useState(""); const [editingThreadTitle, setEditingThreadTitle] = useState(""); const shellRef = useRef(null); const messagesRef = useRef(null); const zIndexRef = useRef(26000); + const bridgeEventSinceRef = useRef(0); + const bridgeRequestThreadsRef = useRef>({}); + const completedBridgeRequestsRef = useRef>(new Set()); + const syncedSettingsKeyRef = useRef(""); const activeExecutor = useMemo( () => executors.find((executor) => executor.id === selectedExecutorId) || executors[0] || null, [executors, selectedExecutorId] ); + const selectedProject = useMemo( + () => projectOptions.find((project) => project.id === selectedProjectId) || null, + [projectOptions, selectedProjectId] + ); + const selectedOpsWorkspace = useMemo( + () => workspaceOptions.find((workspace) => workspace.slug === selectedOpsWorkspaceSlug) || null, + [selectedOpsWorkspaceSlug, workspaceOptions] + ); + const opsWorkspaceSlug = selectedOpsWorkspaceSlug || workspaceSlug; const activeThread = useMemo( () => threads.find((thread) => thread.id === activeThreadId) || threads[0] || localThread(), [activeThreadId, threads] ); const activeMessages = messagesByThread[activeThread.id] || []; + const visibleActiveMessages = visibleConsoleMessages(activeMessages); + const opsActiveContext = useMemo(() => { + const opsContext = { + surface: "ops", + modeId: "ops", + modeTitle: "Ops", + workspaceSlug: opsWorkspaceSlug, + opsWorkspaceSlug, + opsWorkspaceTitle: workspaceLabel(selectedOpsWorkspace) || opsWorkspaceSlug, + opsRouteWorkspaceSlug: workspaceSlug, + opsProjectId: selectedProject?.id || selectedProjectId || "", + opsProjectIdentifier: projectIdentifier(selectedProject), + opsProjectSlug: projectSlug(selectedProject), + opsProjectTitle: projectLabel(selectedProject), + opsScope: selectedProject ? "project" : "workspace", + }; + return { + ...opsContext, + contexts: { + ops: opsContext, + }, + }; + }, [opsWorkspaceSlug, selectedOpsWorkspace, selectedProject, selectedProjectId, workspaceSlug]); + const bridgeActivityRuns = useMemo(() => buildBridgeActivityRuns(bridgeEvents), [bridgeEvents]); + const threadIdForBridgeRun = useCallback((run: TBridgeActivityRun | null | undefined) => { + if (!run) return ""; + return bridgeRequestThreadsRef.current[run.requestId] || bridgeRunThreadId(run); + }, []); + const activeThreadBridgeRuns = useMemo(() => { + if (!activeThread?.id) return []; + return bridgeActivityRuns.filter((run) => threadIdForBridgeRun(run) === activeThread.id); + }, [activeThread?.id, bridgeActivityRuns, threadIdForBridgeRun]); + const activeBridgeRun = + activeThreadBridgeRuns.find((run) => run.status === "running") || + (activeThread.running ? activeThreadBridgeRuns[0] : null); + const threadRunningIds = useMemo(() => { + const ids = new Set(); + threads.forEach((thread) => { + if (thread.running || thread.status === "running") ids.add(thread.id); + }); + bridgeActivityRuns.forEach((run) => { + const targetThreadId = threadIdForBridgeRun(run); + if (targetThreadId && run.status === "running") ids.add(targetThreadId); + }); + return ids; + }, [bridgeActivityRuns, threadIdForBridgeRun, threads]); + const activeThreadRunning = Boolean(sending || activeThread.running || threadRunningIds.has(activeThread.id)); + const activeBridgeRunDuration = useMemo( + () => formatRunDuration(activeBridgeRun, activeThreadRunning), + [activeBridgeRun, activeThreadRunning, bridgeClockTick] + ); + const codexProcessItems = useMemo(() => buildCodexProcessItems(activeBridgeRun), [activeBridgeRun]); + const consoleProcessLabel = "Анализ"; + const consoleProcessHeader = activeBridgeRunDuration + ? `${consoleProcessLabel} ${activeBridgeRunDuration}` + : consoleProcessLabel; + const consoleWorkingPlaceholder = activeThreadRunning ? `${consoleProcessLabel}...` : "Сообщение..."; + const visibleBridgeEvents = activeBridgeRun + ? bridgeRunEvents(activeBridgeRun) + : safeBridgeEvents(bridgeEvents).slice(-12); + const bridgeLogLines = useMemo( + () => visibleBridgeEvents.map(formatBridgeLogLine).filter(Boolean), + [visibleBridgeEvents] + ); const activityLines = useMemo( () => [ @@ -320,8 +993,19 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW ? `executor: ${activeExecutor.name} · ${statusLabel(activeExecutor.status)}` : "executor: not selected", `thread: ${activeThread.title}`, + activeBridgeRun ? `bridge run: ${activeBridgeRun.status} · ${activeBridgeRun.requestId.slice(0, 8)}` : "", + ...bridgeLogLines, ].filter(Boolean), - [actionError, activeExecutor, activeThread.title, registryError, registryNotice, threadError] + [ + actionError, + activeBridgeRun, + activeExecutor, + activeThread.title, + bridgeLogLines, + registryError, + registryNotice, + threadError, + ] ); const updateGeometry = useCallback((next: TConsoleGeometry) => { @@ -393,6 +1077,45 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW } }, [applyExecutorsPayload, workspaceSlug]); + const loadWorkspaces = useCallback(async () => { + try { + setWorkspaceLoadError(""); + const workspaces = await workspaceAIWorkspaceService.listWorkspaces(); + setWorkspaceOptions(workspaces); + setSelectedOpsWorkspaceSlug((current) => { + if (current && workspaces.some((workspace) => workspace.slug === current)) return current; + if (workspaces.some((workspace) => workspace.slug === workspaceSlug)) return workspaceSlug; + return workspaces[0]?.slug || workspaceSlug; + }); + } catch (error) { + setWorkspaceOptions([]); + setWorkspaceLoadError(errorText(error)); + setSelectedOpsWorkspaceSlug((current) => current || workspaceSlug); + } + }, [workspaceSlug]); + + const loadProjects = useCallback(async () => { + try { + setProjectLoadError(""); + const projects = await workspaceAIWorkspaceService.listProjects(opsWorkspaceSlug); + setProjectOptions(projects); + setSelectedProjectId((current) => { + if (current && projects.some((project) => project.id === current)) return current; + if (typeof window !== "undefined") { + try { + const saved = window.localStorage?.getItem(`${AI_CONSOLE_PROJECT_KEY_PREFIX}${opsWorkspaceSlug}`) || ""; + if (saved && projects.some((project) => project.id === saved)) return saved; + } catch {} + } + if (projects.length === 1) return projects[0].id; + return ""; + }); + } catch (error) { + setProjectOptions([]); + setProjectLoadError(errorText(error)); + } + }, [opsWorkspaceSlug]); + const loadThreadMessages = useCallback( async (threadId: string) => { if (!threadId || threadId === LOCAL_THREAD_ID) return; @@ -401,7 +1124,7 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW const payload = await workspaceAIWorkspaceService.listThreadMessages(workspaceSlug, threadId, { limit: 200 }); setMessagesByThread((current) => ({ ...current, - [threadId]: (payload.messages || []).map(messageFromApi), + [threadId]: dedupeConsoleMessages((payload.messages || []).map(messageFromApi)), })); } catch (error) { setThreadError(errorText(error)); @@ -413,7 +1136,7 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW const loadThreads = useCallback(async () => { try { setThreadError(""); - const payload = await workspaceAIWorkspaceService.listThreads(workspaceSlug, { surface: "ops", limit: 100 }); + const payload = await workspaceAIWorkspaceService.listThreads(workspaceSlug, { limit: 100 }); const nextThreads = (payload.threads || []) .filter((thread) => thread.lifecycleState !== "archived") .map(threadFromApi); @@ -439,17 +1162,63 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW useEffect(() => { if (!open) return; void loadRegistry(); + void loadWorkspaces(); + void loadProjects(); void loadThreads(); - }, [loadRegistry, loadThreads, open]); + }, [loadProjects, loadRegistry, loadThreads, loadWorkspaces, open]); useEffect(() => { if (!open) return; const timer = window.setInterval(() => { void loadRegistry(); + void loadWorkspaces(); + void loadProjects(); void loadThreads(); }, 30000); return () => window.clearInterval(timer); - }, [loadRegistry, loadThreads, open]); + }, [loadProjects, loadRegistry, loadThreads, loadWorkspaces, open]); + + useEffect(() => { + if (typeof window === "undefined") return; + try { + window.localStorage?.setItem(AI_CONSOLE_WORKSPACE_KEY, opsWorkspaceSlug); + } catch {} + }, [opsWorkspaceSlug]); + + useEffect(() => { + if (typeof window === "undefined") return; + try { + window.localStorage?.setItem(`${AI_CONSOLE_PROJECT_KEY_PREFIX}${opsWorkspaceSlug}`, selectedProjectId); + } catch {} + }, [opsWorkspaceSlug, selectedProjectId]); + + useEffect(() => { + if (!open) return; + const payload: { + selectedExecutorId?: string; + activeContext: typeof opsActiveContext; + enabledToolPacks: string[]; + metadata: Record; + } = { + activeContext: opsActiveContext, + enabledToolPacks: ["ops", "engine"], + metadata: { + source: "ops-ai-workspace-console", + updatedAt: new Date().toISOString(), + }, + }; + if (activeExecutor?.id) payload.selectedExecutorId = activeExecutor.id; + const syncKey = JSON.stringify({ + selectedExecutorId: payload.selectedExecutorId || null, + activeContext: payload.activeContext, + enabledToolPacks: payload.enabledToolPacks, + }); + if (syncedSettingsKeyRef.current === syncKey) return; + syncedSettingsKeyRef.current = syncKey; + void workspaceAIWorkspaceService.updateSettings(workspaceSlug, payload).catch(() => { + syncedSettingsKeyRef.current = ""; + }); + }, [activeExecutor?.id, open, opsActiveContext, workspaceSlug]); useEffect(() => { if (!open || activeThread.id === LOCAL_THREAD_ID) return; @@ -465,6 +1234,174 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW }); }, [activeMessages.length, activeThread.id, open]); + useEffect(() => { + if (!open || !activeExecutor?.id) { + setBridgeEvents([]); + bridgeEventSinceRef.current = 0; + return; + } + let cancelled = false; + bridgeEventSinceRef.current = 0; + setBridgeEvents([]); + + const poll = async () => { + try { + const payload = await workspaceAIWorkspaceService.listBridgeEvents(workspaceSlug, activeExecutor.id, { + since: bridgeEventSinceRef.current, + limit: 400, + }); + if (cancelled) return; + bridgeEventSinceRef.current = Math.max(bridgeEventSinceRef.current, Number(payload.latestEventId || 0)); + const payloadEvents = safeBridgeEvents(payload.events || []); + if (!payloadEvents.length) return; + setBridgeEvents((current) => { + const seen = new Set(current.map((event) => String(event.id))); + const next = [...current, ...payloadEvents.filter((event) => !seen.has(String(event.id)))]; + return next.slice(-MAX_BRIDGE_EVENTS); + }); + } catch { + if (!cancelled) { + setThreadError((current) => current); + } + } + }; + + const prime = async () => { + try { + const payload = await workspaceAIWorkspaceService.listBridgeEvents(workspaceSlug, activeExecutor.id, { + since: "latest", + limit: 1, + }); + if (!cancelled) bridgeEventSinceRef.current = Number(payload.latestEventId || 0); + } catch {} + }; + + void prime().then(() => { + if (!cancelled) void poll(); + }); + const timer = window.setInterval(() => void poll(), 1000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [activeExecutor?.id, open, workspaceSlug]); + + useEffect(() => { + if (!open || !activeThreadRunning) return; + const timer = window.setInterval(() => setBridgeClockTick((current) => current + 1), 1000); + return () => window.clearInterval(timer); + }, [activeThreadRunning, open]); + + useEffect(() => { + if (!open || !bridgeActivityRuns.length) return; + + const markThread = (threadId: string, patch: Pick) => { + setThreads((current) => { + let changed = false; + const next = current.map((thread) => { + if (thread.id !== threadId) return thread; + const merged = { + ...thread, + running: patch.running, + status: patch.status, + updatedAt: patch.updatedAt || thread.updatedAt, + }; + if ( + merged.running !== thread.running || + merged.status !== thread.status || + merged.updatedAt !== thread.updatedAt + ) { + changed = true; + } + return merged; + }); + return changed ? next : current; + }); + }; + + bridgeActivityRuns.forEach((run) => { + const targetThreadId = threadIdForBridgeRun(run); + if (!targetThreadId) return; + + if (run.status === "running") { + markThread(targetThreadId, { running: true, status: "running", updatedAt: run.updatedAt || run.startedAt }); + return; + } + + const finalAssistantEvent = bridgeRunAssistantEvent(run); + const finalAssistantText = finalAssistantEvent ? assistantTextFromBridgeEvent(finalAssistantEvent) : ""; + if (run.status === "completed" && !finalAssistantText && !bridgeRunWasStopped(run)) { + markThread(targetThreadId, { running: true, status: "running", updatedAt: run.updatedAt || run.startedAt }); + return; + } + + if (completedBridgeRequestsRef.current.has(run.requestId)) return; + completedBridgeRequestsRef.current.add(run.requestId); + markThread(targetThreadId, { running: false, status: run.status, updatedAt: run.updatedAt || run.startedAt }); + + const responseText = finalAssistantText; + if (!responseText) return; + const responseKind = "bridge_final"; + const responseEvent = finalAssistantEvent; + + const localMessageId = `bridge:${run.requestId}:assistant`; + const localMessage: TConsoleMessage = { + id: localMessageId, + role: "assistant", + text: responseText, + payload: { kind: responseKind, requestId: run.requestId, executorId: activeExecutor?.id || null }, + createdAt: responseEvent?.at || run.updatedAt || new Date().toISOString(), + }; + + setMessagesByThread((current) => { + const existing = current[targetThreadId] || []; + const normalizedResponse = normalizeMessageText(responseText); + if ( + existing.some( + (message) => + message.id === localMessageId || + bridgeResultRequestIdFromMessage(message) === run.requestId || + (message.role === "assistant" && normalizeMessageText(message.text) === normalizedResponse) + ) + ) { + return current; + } + return { + ...current, + [targetThreadId]: [...existing, localMessage], + }; + }); + + if (targetThreadId === LOCAL_THREAD_ID) return; + const existingMessages = messagesByThread[targetThreadId] || []; + if (existingMessages.some((message) => bridgeResultRequestIdFromMessage(message) === run.requestId)) return; + void workspaceAIWorkspaceService + .createThreadMessage(workspaceSlug, targetThreadId, { + role: "assistant", + content: responseText, + payload: { + kind: responseKind, + requestId: run.requestId, + executorId: activeExecutor?.id || null, + }, + }) + .then((payload) => { + if (!payload?.message) return; + setMessagesByThread((current) => { + const existing = current[targetThreadId] || []; + if (existing.some((message) => message.id === payload.message.id)) return current; + return { + ...current, + [targetThreadId]: existing.map((message) => + message.id === localMessageId ? messageFromApi(payload.message) : message + ), + }; + }); + }) + .catch(() => undefined); + }); + }, [activeExecutor?.id, bridgeActivityRuns, messagesByThread, open, threadIdForBridgeRun, workspaceSlug]); + useEffect(() => { if (fullscreen) return; try { @@ -551,6 +1488,16 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW [runRegistryAction, updateRegistry, workspaceSlug] ); + const checkExecutor = useCallback( + async (executor: TAIWorkspaceExecutor) => + runRegistryAction( + `check:${executor.id}`, + async () => updateRegistry(await workspaceAIWorkspaceService.checkExecutor(workspaceSlug, executor.id)), + "Проверка выполнена" + ), + [runRegistryAction, updateRegistry, workspaceSlug] + ); + const createExecutor = useCallback( async (input: TAIWorkspaceExecutorInput) => runRegistryAction( @@ -590,12 +1537,14 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW await updateRegistry(payload); window.location.href = workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, executorId, { port, + opsWorkspaceSlug, + opsProjectId: selectedProjectId, }); }, "Installer подготовлен" ); }, - [runRegistryAction, updateRegistry, workspaceSlug] + [opsWorkspaceSlug, runRegistryAction, selectedProjectId, updateRegistry, workspaceSlug] ); const createThread = async () => { @@ -604,7 +1553,7 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW const payload = await workspaceAIWorkspaceService.createThread(workspaceSlug, { title: "Новая тема", selectedExecutorId: activeExecutor?.id || null, - activeContext: { surface: "ops", workspaceSlug }, + activeContext: opsActiveContext, metadata: {}, }); const next = threadFromApi(payload.thread); @@ -622,7 +1571,7 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW const payload: TAIWorkspaceThreadResponse = await workspaceAIWorkspaceService.createThread(workspaceSlug, { title: titleFromMessage(messageText), selectedExecutorId: activeExecutor?.id || null, - activeContext: { surface: "ops", workspaceSlug }, + activeContext: opsActiveContext, metadata: {}, }); const next = threadFromApi(payload.thread); @@ -636,10 +1585,61 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW } }; + const appendAssistantMessage = async ( + threadId: string, + text: string, + payload: Record = {}, + status: TConsoleThread["status"] = "completed" + ) => { + const localMessage: TConsoleMessage = { + id: makeId(), + role: "assistant", + text, + payload, + createdAt: new Date().toISOString(), + }; + setMessagesByThread((current) => ({ + ...current, + [threadId]: [...(current[threadId] || []), localMessage], + })); + setThreads((current) => + current.map((item) => + item.id === threadId + ? { + ...item, + running: false, + status, + updatedAt: localMessage.createdAt, + } + : item + ) + ); + if (threadId === LOCAL_THREAD_ID) return localMessage; + + try { + const response = await workspaceAIWorkspaceService.createThreadMessage(workspaceSlug, threadId, { + role: "assistant", + content: text, + payload, + }); + if (!response?.message) return localMessage; + const persistedMessage = messageFromApi(response.message); + setMessagesByThread((current) => ({ + ...current, + [threadId]: (current[threadId] || []).map((message) => + message.id === localMessage.id ? persistedMessage : message + ), + })); + return persistedMessage; + } catch { + return localMessage; + } + }; + const submitMessage = async (event?: FormEvent) => { event?.preventDefault(); const text = draft.trim(); - if (!text || sending) return; + if (!text || activeThreadRunning) return; setDraft(""); setSending(true); setThreadError(""); @@ -659,7 +1659,13 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW payload: { surface: "ops", selectedExecutorId: activeExecutor?.id || null, - workspaceSlug, + workspaceSlug: opsWorkspaceSlug, + opsWorkspaceSlug, + opsWorkspaceTitle: workspaceLabel(selectedOpsWorkspace) || opsWorkspaceSlug, + opsRouteWorkspaceSlug: workspaceSlug, + opsProjectId: selectedProject?.id || selectedProjectId || "", + opsProjectIdentifier: projectIdentifier(selectedProject), + opsProjectTitle: projectLabel(selectedProject), }, }); setMessagesByThread((current) => ({ @@ -669,10 +1675,122 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW messageFromApi(payload.message), ], })); + const offlineMessage = !activeExecutor + ? "Bridge offline: устройство не выбрано. Сообщение сохранено в общей теме." + : activeExecutor.status !== "online" + ? `Bridge offline: устройство ${activeExecutor.name || "Codex worker"} не подключено к AI Workspace Hub. Сообщение сохранено в общей теме.` + : ""; + if (offlineMessage) { + setThreadError(offlineMessage); + await appendAssistantMessage( + thread.id, + offlineMessage, + { + kind: "bridge_failure", + surface: "ops", + executorId: activeExecutor?.id || null, + selectedExecutorId: activeExecutor?.id || null, + error: "bridge_agent_offline", + }, + "failed" + ); + return; + } + try { + const dispatch = await workspaceAIWorkspaceService.dispatchThreadMessage(workspaceSlug, thread.id, { + messageId: payload.message.id, + selectedExecutorId: activeExecutor?.id || thread.selectedExecutorId || null, + modeId: "ops", + modeTitle: "Ops", + remoteControlMode: "remote", + context: opsActiveContext, + }); + const requestId = String(dispatch.bridge?.requestId || "").trim(); + if (requestId) { + bridgeRequestThreadsRef.current[requestId] = thread.id; + setThreads((current) => + current.map((item) => + item.id === thread.id + ? { + ...item, + running: true, + status: "running", + updatedAt: new Date().toISOString(), + } + : item + ) + ); + } else if (dispatch.assistantMessage) { + const assistantMessage = dispatch.assistantMessage; + const assistantPayload = + assistantMessage.payload && typeof assistantMessage.payload === "object" ? assistantMessage.payload : {}; + const assistantStatus = assistantPayload.kind === "bridge_failure" ? "failed" : "completed"; + setMessagesByThread((current) => { + const existing = current[thread.id] || []; + if (existing.some((message) => message.id === assistantMessage.id)) return current; + return { + ...current, + [thread.id]: [...existing, messageFromApi(assistantMessage)], + }; + }); + setThreads((current) => + current.map((item) => + item.id === thread.id + ? { + ...item, + running: false, + status: assistantStatus, + updatedAt: assistantMessage.createdAt || new Date().toISOString(), + } + : item + ) + ); + } else if (dispatch.bridge && dispatch.bridge.accepted === false) { + const bridgeMessage = (dispatch.bridge.response || {}) as Record; + const reply = String( + bridgeMessage.text || + bridgeMessage.content || + bridgeMessage.reply || + bridgeMessage.message || + dispatch.bridge.error || + "" + ).trim(); + if (reply) { + await appendAssistantMessage( + thread.id, + redactAiWorkspacePublicText(reply), + { + kind: "bridge_failure", + surface: "ops", + executorId: activeExecutor?.id || null, + error: reply, + }, + "failed" + ); + } + } + } catch (dispatchError) { + const message = `Bridge request failed: ${errorText(dispatchError)}`; + setThreadError(message); + await appendAssistantMessage( + thread.id, + message, + { + kind: "bridge_failure", + surface: "ops", + executorId: activeExecutor?.id || null, + error: errorText(dispatchError), + }, + "failed" + ); + } setThreads((current) => current.map((item) => item.id === thread.id - ? { ...item, title: item.title === "Новая тема" ? titleFromMessage(text) : item.title } + ? { + ...item, + title: item.title === "Новая тема" ? titleFromMessage(text) : item.title, + } : item ) ); @@ -745,7 +1863,7 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW
    {activeExecutor - ? `${activeExecutor.name} · Codex Remote · ${statusLabel(activeExecutor.status)}` + ? `${activeExecutor.name} · Ops · ${statusLabel(activeExecutor.status)}` : "Устройство не выбрано"}

    AI Workspace Console

    @@ -912,7 +2030,7 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW ) : ( {thread.title} )} - {sending && thread.id === activeThread.id ? ( + {threadRunningIds.has(thread.id) ? (