cflx 0.6.11

Conflux – a spec-driven parallel coding orchestrator that runs AI agents on git worktrees
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Plus, X, Terminal as TerminalIcon, ChevronUp, ChevronDown } from 'lucide-react';
import { TerminalTab } from './TerminalTab';
import {
  createTerminalSession,
  deleteTerminalSession,
  listTerminalSessions,
  TerminalSessionInfo,
} from '../api/restClient';

interface TerminalPanelProps {
  /** Project ID for terminal session creation */
  projectId: string;
  /** Root parameter matching file browser context */
  root: string;
  /** Whether the panel is expanded */
  isExpanded: boolean;
  /** Callback to toggle expansion */
  onToggleExpand: () => void;
}

interface TabInfo {
  session: TerminalSessionInfo;
  /** Whether this tab has been attached (WebSocket opened) */
  attached: boolean;
}

/**
 * Extract a display label from a root string.
 * "worktree:feature-x" → "feature-x", "base" → "base"
 */
function rootToLabel(root: string): string {
  if (root.startsWith('worktree:')) {
    return root.slice('worktree:'.length);
  }
  return root || 'terminal';
}

export function TerminalPanel({ projectId, root, isExpanded, onToggleExpand }: TerminalPanelProps) {
  // allTabs stores every session we know about (across all roots).
  // We filter for display based on current root.
  const [allTabs, setAllTabs] = useState<TabInfo[]>([]);
  const [activeTabId, setActiveTabId] = useState<string | null>(null);
  const [isCreating, setIsCreating] = useState(false);
  const [hasRestoredSessions, setHasRestoredSessions] = useState(false);
  const allTabsRef = useRef(allTabs);
  allTabsRef.current = allTabs;

  // Tabs visible for the current root context
  const visibleTabs = allTabs.filter(
    (t) => t.session.project_id === projectId && t.session.root === root,
  );

  // Count of all sessions (across all roots) for the badge
  const totalCount = allTabs.length;

  // Restore existing sessions on mount
  useEffect(() => {
    let cancelled = false;
    async function restoreSessions() {
      try {
        const sessions = await listTerminalSessions();
        if (cancelled) return;
        if (sessions.length > 0) {
          const restoredTabs: TabInfo[] = sessions.map((session) => ({
            session,
            attached: false,
          }));
          setAllTabs(restoredTabs);

          // Set active tab to the first matching session for current context
          const matching = sessions.filter(
            (s) => s.project_id === projectId && s.root === root,
          );
          if (matching.length > 0) {
            setActiveTabId(matching[0].id);
          }
        }
      } catch (err) {
        console.error('Failed to restore terminal sessions:', err);
      } finally {
        if (!cancelled) {
          setHasRestoredSessions(true);
        }
      }
    }
    restoreSessions();
    return () => {
      cancelled = true;
    };
    // Only run once on mount
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Auto-create a terminal session when panel expands and no matching session exists
  const hasAutoCreatedForContext = useRef<string | null>(null);
  useEffect(() => {
    if (!hasRestoredSessions) return;
    const contextKey = `${projectId}:${root}`;
    if (isExpanded && visibleTabs.length === 0 && hasAutoCreatedForContext.current !== contextKey && !isCreating) {
      hasAutoCreatedForContext.current = contextKey;
      handleCreateTab();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isExpanded, hasRestoredSessions, visibleTabs.length, projectId, root]);

  // When root changes, switch active tab to a matching session if available
  useEffect(() => {
    const matching = allTabsRef.current.filter(
      (t) => t.session.project_id === projectId && t.session.root === root,
    );
    if (matching.length > 0) {
      // If active tab is already matching, keep it; otherwise switch to first match
      const currentlyActive = matching.find((t) => t.session.id === activeTabId);
      if (!currentlyActive) {
        setActiveTabId(matching[0].session.id);
      }
    } else {
      setActiveTabId(null);
    }
  }, [projectId, root, activeTabId]);

  const handleCreateTab = useCallback(async () => {
    if (isCreating) return;
    setIsCreating(true);
    try {
      const session = await createTerminalSession({ project_id: projectId, root, rows: 24, cols: 80 });
      const newTab: TabInfo = { session, attached: false };
      setAllTabs((prev) => [...prev, newTab]);
      setActiveTabId(session.id);
    } catch (err) {
      console.error('Failed to create terminal session:', err);
    } finally {
      setIsCreating(false);
    }
  }, [projectId, root, isCreating]);

  const handleCloseTab = useCallback(
    async (sessionId: string) => {
      try {
        await deleteTerminalSession(sessionId);
      } catch (err) {
        console.error('Failed to delete terminal session:', err);
      }
      setAllTabs((prev) => prev.filter((t) => t.session.id !== sessionId));
      setActiveTabId((prev) => {
        if (prev === sessionId) {
          const remaining = allTabsRef.current.filter(
            (t) => t.session.id !== sessionId && t.session.project_id === projectId && t.session.root === root,
          );
          return remaining.length > 0 ? remaining[remaining.length - 1].session.id : null;
        }
        return prev;
      });
    },
    [projectId, root],
  );

  const handleSelectTab = useCallback((sessionId: string) => {
    setActiveTabId(sessionId);
  }, []);

  return (
    <div className="flex flex-col border-t border-[#27272a]">
      {/* Toggle bar */}
      <button
        onClick={onToggleExpand}
        className="flex items-center gap-2 px-3 py-1.5 text-xs text-[#a1a1aa] transition-colors hover:bg-[#27272a]/50"
      >
        <TerminalIcon className="size-3.5" />
        <span>Terminal</span>
        {totalCount > 0 && (
          <span className="rounded bg-[#27272a] px-1.5 py-0.5 text-[10px] text-[#71717a]">
            {totalCount}
          </span>
        )}
        <span className="flex-1" />
        {isExpanded ? (
          <ChevronDown className="size-3.5" />
        ) : (
          <ChevronUp className="size-3.5" />
        )}
      </button>

      {/* Terminal content (only rendered when expanded) */}
      {isExpanded && (
        <div className="flex flex-col" style={{ height: '300px' }}>
          {/* Tab bar */}
          <div className="flex items-center border-b border-[#27272a] bg-[#0a0a0a]">
            <div className="flex flex-1 items-center overflow-x-auto">
              {visibleTabs.map((tab) => (
                <div
                  key={tab.session.id}
                  className={`group flex items-center gap-1 border-r border-[#27272a] px-2 py-1 text-xs ${
                    activeTabId === tab.session.id
                      ? 'bg-[#18181b] text-[#e4e4e7]'
                      : 'text-[#71717a] hover:bg-[#18181b]/50'
                  }`}
                >
                  <button
                    onClick={() => handleSelectTab(tab.session.id)}
                    className="flex items-center gap-1"
                  >
                    <TerminalIcon className="size-3" />
                    <span className="max-w-[120px] truncate">
                      {rootToLabel(tab.session.root)}
                    </span>
                  </button>
                  <button
                    onClick={(e) => {
                      e.stopPropagation();
                      handleCloseTab(tab.session.id);
                    }}
                    className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[#27272a] group-hover:opacity-100"
                    title="Close terminal"
                  >
                    <X className="size-3" />
                  </button>
                </div>
              ))}
            </div>
            <button
              onClick={handleCreateTab}
              disabled={isCreating}
              className="p-1.5 text-[#71717a] transition-colors hover:bg-[#27272a] hover:text-[#a1a1aa] disabled:opacity-50"
              title="New terminal"
            >
              <Plus className="size-3.5" />
            </button>
          </div>

          {/* Terminal content area */}
          <div className="relative flex-1 overflow-hidden bg-[#0a0a0a]">
            {visibleTabs.length === 0 && (
              <div className="flex h-full items-center justify-center">
                <p className="text-xs text-[#52525b]">No terminal sessions</p>
              </div>
            )}
            {/* Render all tabs but only show the active one.
                Tabs for other roots are kept alive but hidden. */}
            {allTabs.map((tab) => (
              <div
                key={tab.session.id}
                className="absolute inset-0"
                style={{ display: activeTabId === tab.session.id ? 'block' : 'none' }}
              >
                <TerminalTab
                  sessionId={tab.session.id}
                  isActive={activeTabId === tab.session.id}
                />
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}