Newer
Older
cortex-hub / ui / client-app / src / pages / CodingAssistantPage.js
import React, { useState, useRef, useEffect } from "react";
import ChatArea from "../components/ChatArea";
import SessionSidebar from "../components/SessionSidebar";
import MultiNodeConsole from "../components/MultiNodeConsole";
import useCodeAssistant from "../hooks/useCodeAssistant";
import {
  updateSession, getSessionNodeStatus, attachNodesToSession,
  detachNodeFromSession, getUserAccessibleNodes
} from "../services/apiService";

const CodeAssistantPage = () => {
  const pageContainerRef = useRef(null);

  const {
    chatHistory,
    isProcessing,
    errorMessage,
    showErrorModal,
    tokenUsage,
    handleSendChat,
    setShowErrorModal,
    handleSwitchSession,
    sessionId,
    userConfigData,
    localActiveLLM,
    setLocalActiveLLM,
    isConfigured,
    missingConfigs
  } = useCodeAssistant({ pageContainerRef });

  const [showConfigModal, setShowConfigModal] = useState(false);
  const [showNodeSelector, setShowNodeSelector] = useState(false);
  const [sidebarRefreshTick, setSidebarRefreshTick] = useState(0);

  // M3/M6 Node Integration State
  const [sessionNodeStatus, setSessionNodeStatus] = useState({}); // node_id -> { status, last_sync }
  const [accessibleNodes, setAccessibleNodes] = useState([]);
  const [attachedNodeIds, setAttachedNodeIds] = useState([]);
  const [workspaceId, setWorkspaceId] = useState("");
  const [showConsole, setShowConsole] = useState(false);

  const fetchNodeInfo = async () => {
    if (!sessionId) return;
    try {
      const [status, nodes] = await Promise.all([
        getSessionNodeStatus(sessionId),
        getUserAccessibleNodes()
      ]);
      setSessionNodeStatus(status.node_sync_status || {});
      setAttachedNodeIds(status.attached_node_ids || []);
      setWorkspaceId(status.sync_workspace_id || "");
      setAccessibleNodes(nodes);
    } catch (e) {
      console.warn("M3: Failed to fetch session node info", e);
    }
  };

  useEffect(() => {
    fetchNodeInfo();
    const interval = setInterval(fetchNodeInfo, 5000); // Polling status
    return () => clearInterval(interval);
  }, [sessionId]);

  const handleToggleNode = async (nodeId, isAttached) => {
    try {
      if (isAttached) {
        await detachNodeFromSession(sessionId, nodeId);
      } else {
        await attachNodesToSession(sessionId, [nodeId]);
      }
      fetchNodeInfo();
    } catch (err) {
      alert(err.message);
    }
  };

  const handleSaveQuickConfig = async () => {
    try {
      if (sessionId && localActiveLLM) {
        await updateSession(sessionId, { provider_name: localActiveLLM });
        setSidebarRefreshTick(t => t + 1);
      }
      setShowConfigModal(false);
    } catch (e) {
      console.error("Failed to update session configs:", e);
    }
  };

  useEffect(() => {
    if (pageContainerRef.current) {
      pageContainerRef.current.scrollTop = pageContainerRef.current.scrollHeight;
    }
  }, [chatHistory]);

  // Antigravity: Auto-show console when processing
  useEffect(() => {
    if (isProcessing && attachedNodeIds.length > 0) {
      setShowConsole(true);
    }
  }, [isProcessing, attachedNodeIds]);

  return (
    <div className="flex flex-col flex-grow h-full overflow-hidden bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 relative">
      <SessionSidebar
        featureName="coding_assistant"
        currentSessionId={sessionId}
        onSwitchSession={handleSwitchSession}
        onNewSession={() => handleSendChat("/new")}
        refreshTick={sidebarRefreshTick}
      />

      {/* Main content area */}
      <div className="flex-grow flex flex-col min-h-0 bg-transparent" ref={pageContainerRef}>
        <div className="max-w-4xl mx-auto w-full h-full pt-12 flex flex-col min-h-0">
          {/* Chat Area & Header */}
          <div className="flex flex-col h-full min-h-0">
            <div className="flex-grow p-4 bg-white dark:bg-gray-800 rounded-xl rounded-b-none shadow-lg border border-gray-200 dark:border-gray-700 flex flex-col min-h-0 transition-all duration-300">
              <h2 className="text-xl font-bold mb-4 flex justify-between items-center border-b border-gray-100 dark:border-gray-700 pb-4">
                <div className="flex items-center gap-3">
                  <div className="p-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-lg">
                    <svg className="w-6 h-6 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
                    </svg>
                  </div>
                  <div className="flex flex-col">
                    <span className="text-lg">Coding Assistant</span>
                    <span className="text-[10px] text-gray-500 font-medium uppercase tracking-widest whitespace-nowrap">
                      Mesh: {attachedNodeIds.length} Nodes Active
                    </span>
                  </div>

                  {/* Nodes Indicator Bar (M3/M6) */}
                  <div className="flex items-center space-x-1 ml-4 border-l pl-4 dark:border-gray-700 h-10">
                    {attachedNodeIds.length === 0 ? (
                      <span className="text-[10px] text-gray-400 italic">No nodes attached</span>
                    ) : (
                      attachedNodeIds.map(nid => {
                        const status = sessionNodeStatus[nid]?.status || 'pending';
                        return (
                          <div key={nid} title={`${nid}: ${status}`} className="relative group">
                            <div className={`w-1.5 h-6 rounded-full ${['connected', 'synced', 'idle', 'online'].includes(status) ? 'bg-green-500' : 'bg-yellow-400 animate-pulse'}`}></div>
                            <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 text-white text-[10px] font-bold rounded opacity-0 group-hover:opacity-100 transition-all whitespace-nowrap pointer-events-none z-50">
                              {nid}: {status.toUpperCase()}
                            </div>
                          </div>
                        )
                      })
                    )}
                    <button
                      onClick={() => setShowNodeSelector(true)}
                      className="ml-2 p-1.5 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-500 hover:text-indigo-600 transition-colors"
                      title="Manage Mesh Nodes"
                    >
                      <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
                      </svg>
                    </button>
                    {attachedNodeIds.length > 0 && (
                      <button
                        onClick={() => setShowConsole(!showConsole)}
                        className={`ml-1 p-1.5 rounded-lg transition-colors ${showConsole ? 'bg-indigo-100 text-indigo-600' : 'text-gray-400 hover:bg-gray-50'}`}
                        title="Toggle Execution Console"
                      >
                        <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
                        </svg>
                      </button>
                    )}
                  </div>

                  {!isConfigured && (
                    <div className="group relative flex items-center">
                      <svg className="w-5 h-5 text-yellow-500 cursor-pointer" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
                      </svg>
                      <div className="absolute left-1/2 -translate-x-1/2 top-full mt-2 w-56 bg-gray-900 text-white text-[11px] rounded shadow-lg p-2.5 opacity-0 group-hover:opacity-100 transition-opacity z-50 pointer-events-none text-left">
                        <p className="font-bold mb-1 text-red-400">Missing Key</p>
                        <ul className="list-disc pl-3 space-y-0.5">
                          {missingConfigs?.map((m, i) => <li key={i}>{m}</li>)}
                        </ul>
                      </div>
                    </div>
                  )}
                  <button
                    onClick={() => setShowConfigModal(true)}
                    className="text-gray-400 hover:text-indigo-600 transition-colors"
                  >
                    <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
                  </button>
                </div>
                <div className="flex items-center space-x-6">
                  <div className="flex flex-col items-end hidden md:flex">
                    <div className="text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-1">
                      Token Usage
                    </div>
                    <div className="flex items-center gap-2">
                      <div className="w-24 h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
                        <div
                          className={`h-full transition-all duration-700 ease-out ${tokenUsage?.percentage > 80 ? 'bg-red-500' : 'bg-indigo-500'}`}
                          style={{ width: `${Math.min(tokenUsage?.percentage || 0, 100)}%` }}
                        ></div>
                      </div>
                      <span className={`text-xs font-mono font-bold ${tokenUsage?.percentage > 80 ? 'text-red-500' : 'text-gray-400'}`}>
                        {tokenUsage?.percentage || 0}%
                      </span>
                    </div>
                  </div>
                  <button
                    onClick={() => handleSendChat("/new")}
                    className="text-xs font-bold px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-all shadow-md hover:shadow-indigo-500/20 active:scale-95"
                  >
                    + NEW
                  </button>
                </div>
              </h2>

              <div className="flex-grow flex flex-col min-h-0">
                <ChatArea
                  chatHistory={chatHistory}
                  onSendMessage={handleSendChat}
                  isProcessing={isProcessing}
                  featureName="coding_assistant"
                />
              </div>
            </div>

            {/* Antigravity Console (M6) */}
            {showConsole && attachedNodeIds.length > 0 && (
              <div className="h-64 shadow-xl border-x border-b dark:border-gray-800 animate-in slide-in-from-bottom duration-300">
                <MultiNodeConsole attachedNodeIds={attachedNodeIds} />
              </div>
            )}
          </div>
        </div>
      </div>

      {/* Node Selector Modal (M3) */}
      {showNodeSelector && (
        <div className="fixed inset-0 bg-gray-900/40 backdrop-blur-sm flex justify-center items-center z-50">
          <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden animate-in zoom-in-95 duration-200">
            <div className="px-6 py-5 border-b dark:border-gray-700 flex justify-between items-center bg-gray-50 dark:bg-gray-800/50">
              <h3 className="font-bold text-gray-900 dark:text-white flex items-center gap-2">
                <svg className="w-5 h-5 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
                Mesh Node Selection
              </h3>
              <button onClick={() => setShowNodeSelector(false)} className="text-gray-400 hover:text-gray-600">
                <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
              </button>
            </div>
            <div className="p-6">
              <p className="text-xs text-gray-500 dark:text-gray-400 mb-6 font-medium">
                Select agent nodes to attach to this session. Attached nodes share the workspace <span className="font-mono bg-gray-100 dark:bg-gray-700 px-1 py-0.5 rounded italic">{workspaceId}</span>.
              </p>

              <div className="space-y-2 max-h-80 overflow-y-auto pr-2">
                {accessibleNodes.length === 0 && <span className="text-sm text-gray-400 italic">No nodes available for your account.</span>}
                {accessibleNodes.map(node => {
                  const isAttached = attachedNodeIds.includes(node.node_id);
                  const isOnline = node.last_status === 'online' || node.last_status === 'idle';

                  return (
                    <div
                      key={node.node_id}
                      className={`flex items-center justify-between p-4 rounded-xl border-2 transition-all ${isAttached ? 'border-indigo-500 bg-indigo-50/50 dark:bg-indigo-900/10' : 'border-gray-100 dark:border-gray-700 hover:border-gray-200'}`}
                    >
                      <div className="flex items-center space-x-3">
                        <div className={`w-2.5 h-2.5 rounded-full ${isOnline ? 'bg-green-500' : 'bg-gray-300'}`}></div>
                        <div>
                          <p className="font-bold text-sm text-gray-900 dark:text-gray-100">{node.display_name}</p>
                          <p className="text-[10px] text-gray-500 font-mono italic">{node.node_id}</p>
                        </div>
                      </div>
                      <button
                        onClick={() => handleToggleNode(node.node_id, isAttached)}
                        className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all ${isAttached ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/20' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-indigo-500 hover:text-white'}`}
                      >
                        {isAttached ? 'DETACH' : 'ATTACH'}
                      </button>
                    </div>
                  );
                })}
              </div>
            </div>
            <div className="px-6 py-4 border-t dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 flex justify-end">
              <button onClick={() => setShowNodeSelector(false)} className="bg-gray-900 dark:bg-white dark:text-gray-900 text-white px-6 py-2 rounded-xl text-sm font-bold shadow-lg shadow-gray-900/20 active:scale-95 transition-all">
                Done
              </button>
            </div>
          </div>
        </div>
      )}

      {/* Error Modal */}
      {showErrorModal && (
        <div className="fixed inset-0 bg-gray-900/60 backdrop-blur-sm flex justify-center items-center z-50 animate-in fade-in duration-300">
          <div className="bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-2xl max-w-sm w-full text-center border border-red-100 dark:border-red-900/30">
            <div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
              <svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
            </div>
            <h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-white">Attention Required</h2>
            <p className="text-gray-500 dark:text-gray-400 mb-6 text-sm">{errorMessage}</p>
            <button
              onClick={() => setShowErrorModal(false)}
              className="w-full bg-gray-900 dark:bg-white dark:text-gray-900 text-white font-bold py-3 rounded-xl transition-all active:scale-95"
            >
              Understand
            </button>
          </div>
        </div>
      )}

      {showConfigModal && (
        <div className="fixed inset-0 bg-gray-900/40 backdrop-blur-sm flex justify-center items-center z-50">
          <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-in zoom-in-95 duration-200">
            <div className="px-6 py-5 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 flex justify-between items-center">
              <h3 className="text-lg font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
                <svg className="w-5 h-5 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
                Session Engine
              </h3>
              <button onClick={() => setShowConfigModal(false)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-2">
                <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
              </button>
            </div>
            <div className="p-8 space-y-6">
              <div>
                <label className="block text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-2">Active LLM Provider</label>
                <select
                  value={localActiveLLM}
                  onChange={(e) => setLocalActiveLLM(e.target.value)}
                  className="w-full border-2 border-gray-100 dark:border-gray-700 rounded-xl p-3 bg-white dark:bg-gray-800 text-sm font-medium focus:border-indigo-500 focus:ring-0 transition-all outline-none"
                >
                  <option value="">-- Choose Provider --</option>
                  {userConfigData?.effective?.llm?.providers && Object.keys(userConfigData.effective.llm.providers).map(pid => {
                    const modelName = userConfigData.effective.llm.providers[pid].model;
                    return (
                      <option key={pid} value={pid}>
                        {pid} {modelName ? `(${modelName})` : ''}
                      </option>
                    );
                  })}
                </select>
              </div>
            </div>
            <div className="px-8 py-5 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-100 dark:border-gray-700 flex justify-end gap-3">
              <button
                onClick={() => setShowConfigModal(false)}
                className="px-6 py-2.5 text-sm font-bold text-gray-500 hover:text-gray-800 transition-colors"
              >
                Cancel
              </button>
              <button
                onClick={handleSaveQuickConfig}
                className="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-bold text-sm shadow-lg shadow-indigo-500/20 active:scale-95 transition-all"
              >
                Apply Changes
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
};

export default CodeAssistantPage;