summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx
blob: 176728c7c4f8c10447bad9483cfc91199366686b (plain) (tree)
1
2
3
4
5
6
7




                                                                       

                                                              
















                                                                


                                                             



                                             









                                                                    


                                                                    
                                                       





                                                          



                                           





























































                                                                   
















                                                                    







                                        

                                                               


















                                                                                                                                                                                                                        













                                                                                                             
                                                                                                    
                                             

                                                                        
                                                                                       
                   
                                             








                                                                                                                          



















                                                                                                                                                                                                     



                                                                                                                                                  
                    




                                                                      











                                                                                    
                


















                                                                                                       


                                                            
                                                                                            


                












                                                                                               


       
import { useState, useEffect, useCallback } from "react";
import { useTaskSubscription } from "../../hooks/useTaskSubscription";
import type { TaskOutputEvent } from "../../hooks/useTaskSubscription";
import { TaskOutput } from "../mesh/TaskOutput";
import { WorktreeFilesPanel } from "../mesh/WorktreeFilesPanel";
import { OverlayDiffViewer } from "../mesh/OverlayDiffViewer";
import { getTaskOutput, getTaskDiff } from "../../lib/api";

interface TaskSlideOutPanelProps {
  taskId: string;
  taskName?: string;
  isOpen: boolean;
  onClose: () => void;
}

export function TaskSlideOutPanel({
  taskId,
  taskName,
  isOpen,
  onClose,
}: TaskSlideOutPanelProps) {
  const [entries, setEntries] = useState<TaskOutputEvent[]>([]);
  const [isStreaming, setIsStreaming] = useState(false);
  const [loadingHistory, setLoadingHistory] = useState(false);
  const [showDiff, setShowDiff] = useState(false);
  const [diffContent, setDiffContent] = useState<string>("");
  const [diffLoading, setDiffLoading] = useState(false);

  // Escape key handler
  useEffect(() => {
    const handleEsc = (e: KeyboardEvent) => {
      // If diff is showing, close diff first; otherwise close panel
      if (e.key === "Escape") {
        if (selectedFileDiff !== null || diffLoading) {
          setSelectedFileDiff(null);
          setSelectedFilePath(null);
          setDiffLoading(false);
        } else {
          onClose();
        }
      }
    };
    if (isOpen) document.addEventListener("keydown", handleEsc);
    return () => document.removeEventListener("keydown", handleEsc);
  }, [isOpen, onClose, selectedFileDiff, diffLoading]);

  // Load historical output when panel opens with a taskId
  useEffect(() => {
    if (!isOpen || !taskId) {
      setEntries([]);
      setIsStreaming(false);
      // Reset diff state when panel closes
      setSelectedFileDiff(null);
      setSelectedFilePath(null);
      setDiffLoading(false);
      return;
    }

    let cancelled = false;
    setLoadingHistory(true);

    getTaskOutput(taskId)
      .then((res) => {
        if (cancelled) return;
        // Map TaskOutputEntry to TaskOutputEvent
        const mapped: TaskOutputEvent[] = res.entries.map((e) => ({
          taskId: e.taskId,
          messageType: e.messageType,
          content: e.content,
          toolName: e.toolName,
          toolInput: e.toolInput,
          isError: e.isError,
          costUsd: e.costUsd,
          durationMs: e.durationMs,
          isPartial: false,
        }));
        setEntries(mapped);
      })
      .catch((err) => {
        if (cancelled) return;
        console.error("Failed to load task output history:", err);
      })
      .finally(() => {
        if (!cancelled) setLoadingHistory(false);
      });

    return () => {
      cancelled = true;
    };
  }, [isOpen, taskId]);

  // Handle live output events
  const handleOutput = useCallback(
    (event: TaskOutputEvent) => {
      if (event.isPartial) return;
      setEntries((prev) => [...prev, event]);
      setIsStreaming(true);
    },
    []
  );

  // Handle task updates (to detect completion)
  const handleUpdate = useCallback(
    (event: { status: string }) => {
      if (
        event.status === "completed" ||
        event.status === "failed" ||
        event.status === "cancelled"
      ) {
        setIsStreaming(false);
      } else if (event.status === "running") {
        setIsStreaming(true);
      }
    },
    []
  );

  // Handle file click to show diff
  const handleFileClick = useCallback(async (_filePath: string) => {
    if (!taskId) return;
    setDiffLoading(true);
    try {
      const result = await getTaskDiff(taskId);
      if (result.success && result.diff) {
        setDiffContent(result.diff);
        setShowDiff(true);
      }
    } catch (e) {
      console.error("Failed to get diff:", e);
    } finally {
      setDiffLoading(false);
    }
  }, [taskId]);

  // Subscribe to live output
  useTaskSubscription({
    taskId: isOpen ? taskId : null,
    subscribeOutput: isOpen && !!taskId,
    onOutput: handleOutput,
    onUpdate: handleUpdate,
  });

  const showingDiff = selectedFileDiff !== null || diffLoading;

  return (
    <>
      {/* Backdrop overlay */}
      <div
        className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${
          isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
        }`}
        onClick={onClose}
      />

      {/* Slide-out panel */}
      <div
        className={`fixed top-0 right-0 h-full w-[550px] max-w-[90vw] z-50 bg-[#0d1117] border-l border-[rgba(117,170,252,0.2)] shadow-xl shadow-black/50 flex flex-col transition-transform duration-300 ease-in-out ${
          isOpen ? "translate-x-0" : "translate-x-full"
        }`}
      >
        {/* Header */}
        <div className="flex items-center justify-between px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.25)] shrink-0">
          <div className="flex items-center gap-2 min-w-0 flex-1">
            {showingDiff && (
              <button
                type="button"
                onClick={() => {
                  setSelectedFileDiff(null);
                  setSelectedFilePath(null);
                  setDiffLoading(false);
                }}
                className="text-[#75aafc] hover:text-white font-mono text-xs transition-colors shrink-0 mr-1"
                title="Back to worktree view"
              >
                &larr;
              </button>
            )}
            <span className="text-[10px] font-mono text-[#75aafc] uppercase tracking-wide shrink-0">
              {showingDiff ? "Diff" : "Task"}
            </span>
            <span className="text-[12px] font-mono text-white truncate">
              {showingDiff ? (selectedFilePath || "Loading...") : (taskName || taskId)}
            </span>
            {!showingDiff && isStreaming && (
              <span className="flex items-center gap-1 px-1.5 py-0.5 bg-green-400/10 border border-green-400/30 shrink-0">
                <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
                <span className="text-green-400 font-mono text-[9px] uppercase">
                  Live
                </span>
              </span>
            )}
          </div>
          <button
            onClick={async () => {
              if (!taskId) return;
              setDiffLoading(true);
              try {
                const result = await getTaskDiff(taskId);
                if (result.success && result.diff) {
                  setDiffContent(result.diff);
                  setShowDiff(true);
                }
              } catch (e) {
                console.error("Failed to get diff:", e);
              } finally {
                setDiffLoading(false);
              }
            }}
            className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff] transition-colors px-1.5 py-0.5 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] shrink-0"
          >
            {diffLoading ? "Loading..." : "View Diff"}
          </button>
          <button
            type="button"
            onClick={onClose}
            className="text-[#7788aa] hover:text-white font-mono text-sm transition-colors ml-2 shrink-0 w-6 h-6 flex items-center justify-center"
          >
            &#x2715;
          </button>
        </div>

        {/* Content */}
        <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
          {showingDiff ? (
            /* Diff view replaces the worktree panel content */
            <div className="flex-1 min-h-0 overflow-y-auto">
              <OverlayDiffViewer
                diff={selectedFileDiff || ""}
                loading={diffLoading}
                onClose={() => {
                  setSelectedFileDiff(null);
                  setSelectedFilePath(null);
                  setDiffLoading(false);
                }}
                title={selectedFilePath ? `Diff: ${selectedFilePath}` : "File Diff"}
              />
            </div>
          ) : (
            <>
              {/* Task Output section (~60% height) */}
              <div className="flex-[3] min-h-0 flex flex-col border-b border-[rgba(117,170,252,0.15)]">
                {loadingHistory ? (
                  <div className="flex-1 flex items-center justify-center">
                    <span className="font-mono text-xs text-[#555] animate-pulse">
                      Loading output...
                    </span>
                  </div>
                ) : (
                  <TaskOutput
                    entries={entries}
                    isStreaming={isStreaming}
                    taskId={taskId}
                  />
                )}
              </div>

          {/* Worktree Changes section (~40% height) */}
          <div className="flex-[2] min-h-0 overflow-y-auto">
            {taskId && <WorktreeFilesPanel taskId={taskId} onFileClick={handleFileClick} />}
          </div>
        </div>
      </div>

      {/* Diff modal */}
      {showDiff && (
        <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
          <div className="max-w-4xl w-full max-h-[80vh]">
            <OverlayDiffViewer
              diff={diffContent}
              onClose={() => setShowDiff(false)}
              title={`Changes in ${taskName || taskId}`}
            />
          </div>
        </div>
      )}
    </>
  );
}