Newer
Older
ExoLauncher / src / App.tsx
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import './index.css';
import logo from './assets/logo.png';

interface QueuedOutput {
  id: string;
  target: string;
  content: string;
  status: 'pending' | 'approved' | 'rejected';
  metadata: string | null;
  comment: string | null;
  created_at: string;
}

const App: React.FC = () => {
  const { t, i18n } = useTranslation();
  const [items, setItems] = useState<QueuedOutput[]>([]);
  const [loading, setLoading] = useState(true);
  const [currentView, setCurrentView] = useState<'pending' | 'settings'>('pending');
  const [sidebarWidth, setSidebarWidth] = useState(280);
  const [masterWidth, setMasterWidth] = useState(320);
  const [resizingType, setResizingType] = useState<'sidebar' | 'master' | null>(null);
  const [selectedItemId, setSelectedItemId] = useState<string | null>(null);

  // Auto-select first item
  useEffect(() => {
    if (items.length > 0 && !selectedItemId) {
      setSelectedItemId(items[0].id);
    }
  }, [items]);

  useEffect(() => {
    document.documentElement.style.setProperty('--dynamic-sidebar-width', `${sidebarWidth}px`);
  }, [sidebarWidth]);

  useEffect(() => {
    document.documentElement.style.setProperty('--dynamic-master-width', `${masterWidth}px`);
  }, [masterWidth]);

  const handleLanguageChange = (lang: string) => {
    i18n.changeLanguage(lang);
  };

  const startResizing = (type: 'sidebar' | 'master') => (e: React.MouseEvent) => {
    setResizingType(type);
    e.preventDefault();
  };

  const stopResizing = () => {
    setResizingType(null);
  };

  const resize = (e: MouseEvent) => {
    if (resizingType === 'sidebar') {
      const newWidth = e.clientX - 64; // 64px is the activity bar width
      if (newWidth > 150 && newWidth < 600) {
        setSidebarWidth(newWidth);
      }
    } else if (resizingType === 'master') {
      const sidebarTotal = 64 + sidebarWidth;
      const newWidth = e.clientX - sidebarTotal;
      if (newWidth > 200 && newWidth < 800) {
        setMasterWidth(newWidth);
      }
    }
  };

  useEffect(() => {
    if (resizingType) {
      window.addEventListener('mousemove', resize);
      window.addEventListener('mouseup', stopResizing);
    } else {
      window.removeEventListener('mousemove', resize);
      window.removeEventListener('mouseup', stopResizing);
    }
    return () => {
      window.removeEventListener('mousemove', resize);
      window.removeEventListener('mouseup', stopResizing);
    };
  }, [resizingType, sidebarWidth]); // need sidebarWidth for master resizing calc

  useEffect(() => {
    document.documentElement.style.setProperty('--dynamic-sidebar-width', `${sidebarWidth}px`);
  }, [sidebarWidth]);

  useEffect(() => {
    document.documentElement.style.setProperty('--dynamic-master-width', `${masterWidth}px`);
  }, [masterWidth]);

  const fetchItems = async (showLoading = true) => {
    try {
      if (showLoading) setLoading(true);
      const response = await fetch('http://localhost:3001/api/pending');
      const data = await response.json();
      setItems(data);
    } catch (err) {
      console.error('Failed to fetch items:', err);
    } finally {
      if (showLoading) setLoading(false);
    }
  };

  useEffect(() => {
    let isMounted = true;
    
    const initialFetch = async () => {
      await fetchItems(true);
    };

    initialFetch();

    // SSE Setup
    const eventSource = new EventSource('http://localhost:3001/api/sse');
    
    eventSource.onmessage = (event) => {
      console.log('SSE Event received:', event.data);
      if (isMounted) {
        fetchItems(false);
      }
    };

    eventSource.onerror = (err) => {
      console.error('SSE Error:', err);
      eventSource.close();
    };

    return () => {
      isMounted = false;
      eventSource.close();
    };
  }, []);

  const handleApprove = async (id: string) => {
    try {
      await fetch(`http://localhost:3001/api/approve/${id}`, { method: 'POST' });
      fetchItems(false);
    } catch (err) {
      alert('Failed to approve');
    }
  };

  const handleReject = async (id: string) => {
    const comment = prompt('Enter rejection feedback:');
    if (comment === null) return;
    try {
      await fetch(`http://localhost:3001/api/reject/${id}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ comment }),
      });
      fetchItems(false);
    } catch (err) {
      alert('Failed to reject');
    }
  };

  const mcpConfigSnippet = {
    "mcpServers": {
      "exolauncher": {
        "type": "sse",
        "url": "http://localhost:3001/mcp/sse"
      }
    }
  };

  useEffect(() => {
    document.documentElement.style.setProperty('--dynamic-sidebar-width', `${sidebarWidth}px`);
  }, [sidebarWidth]);

  return (
    <div className="app-container">
      {/* Sidebar (Dual Pane) */}
      <aside className="sidebar">
        {/* Activity Bar (Icons Only) */}
        <div className="activity-bar">
          <div className="logo-mini">
            <img src={logo} alt="Logo" />
          </div>
          <button 
            className={`activity-item ${currentView === 'pending' ? 'active' : ''}`}
            onClick={() => setCurrentView('pending')}
            title="Pending Queue"
          >
            <svg className="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline>
              <path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path>
            </svg>
          </button>
          <button 
            className={`activity-item ${currentView === 'settings' ? 'active' : ''}`}
            onClick={() => setCurrentView('settings')}
            title="MCP Settings"
          >
            <svg className="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <circle cx="12" cy="12" r="3"></circle>
              <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
            </svg>
          </button>
        </div>

        {/* Side Panel (Details) */}
        <div className="side-panel">
          <div className="panel-header">
            <h3>{currentView === 'pending' ? t('common.dashboard') : t('common.configuration')}</h3>
          </div>
          <nav className="panel-nav">
            {currentView === 'pending' ? (
              <>
                <div className="panel-label">{t('sidebar.queues')}</div>
                <button className="panel-item active">{t('sidebar.pending')}</button>
                <button className="panel-item">{t('sidebar.history')}</button>
              </>
            ) : (
              <>
                <div className="panel-label">{t('sidebar.settings')}</div>
                <button className="panel-item active">{t('sidebar.mcp_servers')}</button>
                <button className="panel-item">{t('sidebar.network')}</button>
              </>
            )}
          </nav>
          <div className="sidebar-footer">
            {t('common.version', { version: '0.0.0' })}
          </div>
        </div>

        {/* Resizer Handle */}
        <div className="resizer" onMouseDown={startResizing('sidebar')} />
      </aside>

      <div className="main-content">
        <header className="main-header">
          <h1>{currentView === 'pending' ? t('views.pending_approvals') : t('views.mcp_settings')}</h1>
          <div className="status-badge">{t('common.status')}</div>
        </header>

        <main className="content">
          {currentView === 'pending' ? (
            loading ? (
              <div className="loading">{t('common.loading')}</div>
            ) : (
              <div className="split-view">
                <div className="master-list">
                  {items.length === 0 ? (
                    <div className="empty-list-message">
                      <p>{t('views.empty')}</p>
                    </div>
                  ) : (
                    items.map((item) => (
                      <div 
                        key={item.id} 
                        className={`list-item ${selectedItemId === item.id ? 'active' : ''}`}
                        onClick={() => setSelectedItemId(item.id)}
                      >
                        <div className="item-meta">
                          <span className="item-target">{item.target}</span>
                          <span className="item-time">{new Date(item.created_at).toLocaleTimeString(i18n.language === 'ja' ? 'ja-JP' : 'en-US', { hour: '2-digit', minute: '2-digit' })}</span>
                        </div>
                        <div className="item-snippet">{item.content.substring(0, 40)}...</div>
                      </div>
                    ))
                  )}
                </div>
                
                {/* Master-Detail Resizer */}
                <div className="resizer-v" onMouseDown={startResizing('master')} />

                <div className="detail-pane">
                  {items.length > 0 && items.find(i => i.id === selectedItemId) ? (
                    (() => {
                      const selectedItem = items.find(i => i.id === selectedItemId)!;
                      return (
                        <div className="preview-container">
                          <div className="preview-header">
                            <div className="header-info">
                              <h2>{selectedItem.target}</h2>
                              <span className="full-timestamp">{new Date(selectedItem.created_at).toLocaleString()}</span>
                            </div>
                            <div className="header-actions">
                              <button className="btn-reject" onClick={() => handleReject(selectedItem.id)}>{t('views.reject')}</button>
                              <button className="btn-approve" onClick={() => handleApprove(selectedItem.id)}>{t('views.approve')}</button>
                            </div>
                          </div>
                          <div className="preview-body">
                            <pre className="full-content">{selectedItem.content}</pre>
                            {selectedItem.metadata && (
                              <div className="detail-metadata">
                                <h4>Metadata</h4>
                                <code>{selectedItem.metadata}</code>
                              </div>
                            )}
                          </div>
                        </div>
                      );
                    })()
                  ) : (
                    <div className="no-selection">
                      <div className="empty-icon">
                        <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
                          <path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1-8.313-12.454z"></path>
                          <path d="M12 10l2 4l4 2l-4 2l-2 4l-2-4l-4-2l4-2z"></path>
                        </svg>
                      </div>
                      <p>{items.length === 0 ? '' : 'Select an item to preview'}</p>
                    </div>
                  )}
                </div>
              </div>
            )
          ) : (
            <div className="settings-view">
              <div className="settings-card">
                <h3>{t('sidebar.mcp_servers')}</h3>
                <p>{t('views.mcp_config_help')}</p>
                <div className="code-block">
                  <pre>{JSON.stringify(mcpConfigSnippet, null, 2)}</pre>
                  <button className="copy-btn" onClick={() => navigator.clipboard.writeText(JSON.stringify(mcpConfigSnippet, null, 2))}>
                    {t('views.copy_json')}
                  </button>
                </div>
                <div className="info-box">
                  <strong>{t('views.note')}:</strong> {t('views.sse_note')}
                </div>
              </div>

              <div className="settings-card">
                <h3>{t('settings.language')}</h3>
                <div className="select-wrapper">
                  <select 
                    className="lang-select"
                    title={t('settings.language')}
                    value={i18n.language}
                    onChange={(e) => handleLanguageChange(e.target.value)}
                  >
                    <option value="en">{t('settings.en')}</option>
                    <option value="ja">{t('settings.ja')}</option>
                  </select>
                </div>
              </div>
            </div>
          )}
        </main>
      </div>
    </div>
  );
};

export default App;