cruise 0.1.34

YAML-driven coding agent workflow orchestrator
Documentation
import { useCallback, useEffect, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { getVersion } from "@tauri-apps/api/app";
import type { Update } from "../lib/updater";
import { checkForUpdate, checkForUpdateManual, downloadAndInstall } from "../lib/updater";
import { listSessions, cleanSessions, getUpdateReadiness } from "../lib/commands";
import type { Session, UpdateReadiness } from "../types";
import { PhaseBadge } from "./PhaseBadge";
import { formatLocalTime } from "../lib/format";
import { isApprovalReady } from "../lib/sessionActions";

type UpdateState = "available" | "downloading" | "error";

function Spinner({ color = "border-gray-400" }: { color?: string }) {
  return (
    <span className={`inline-block w-3 h-3 rounded-full border-2 border-t-transparent animate-spin ${color}`} />
  );
}

interface SessionSidebarProps {
  selectedId: string | null;
  onSelect: (session: Session) => void;
  onNewSession: () => void;
  onRunAll: () => void;
  /** When true, Run All is actively executing -- button is enabled regardless of pending sessions and shown in active state. */
  runAllActive?: boolean;
  onRefreshRef?: MutableRefObject<(() => void) | null>;
  /** Called after each load() when the currently selected session appears in
   *  the result, passing the latest DTO so the parent can stay in sync without
   *  triggering a view-change side effect (i.e. never call onSelect here). */
  onSelectedSessionUpdated?: (session: Session) => void;
  /** Session IDs that currently have a fix in progress; their rows show "Fixing" instead of "Awaiting Approval". */
  fixingSessionIds?: ReadonlySet<string>;
  /** Called after each successful load() when the fingerprint changes.
   *  App uses this to detect phase transitions (approval-ready, completed)
   *  and fire notifications without depending on the 3-second idle poll. */
  onSessionsChanged?: (sessions: Session[]) => void;
  /** Called when the user clicks the Settings button. */
  onSettings?: () => void;
}

export function SessionSidebar({ selectedId, onSelect, onNewSession, onRunAll, runAllActive, onRefreshRef, onSelectedSessionUpdated: onSelectedSessionUpdatedProp, onSessionsChanged: onSessionsChangedProp, fixingSessionIds, onSettings }: SessionSidebarProps) {
  // Stable refs so load() can access the latest props without re-creating itself
  const onSelectedSessionUpdatedRef = useRef(onSelectedSessionUpdatedProp);
  onSelectedSessionUpdatedRef.current = onSelectedSessionUpdatedProp;
  const onSessionsChangedRef = useRef(onSessionsChangedProp);
  onSessionsChangedRef.current = onSessionsChangedProp;
  const selectedIdRef = useRef(selectedId);
  selectedIdRef.current = selectedId;
  const [sessions, setSessions] = useState<Session[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [cleaning, setCleaning] = useState(false);
  const [cleanMessage, setCleanMessage] = useState<string | null>(null);
  const [version, setVersion] = useState<string | null>(null);
  const [update, setUpdate] = useState<Update | null>(null);
  const [updateState, setUpdateState] = useState<UpdateState>("available");
  const [updateReadiness, setUpdateReadiness] = useState<UpdateReadiness | null>(null);
  const [errorMsg, setErrorMsg] = useState("");
  const [manualCheck, setManualCheck] = useState<"idle" | "checking" | "upToDate" | { error: string }>("idle");
  const lastFingerprintRef = useRef("");
  const inflightRef = useRef(false);

  const load = useCallback(async (silent = false) => {
    if (inflightRef.current) return;
    inflightRef.current = true;
    if (!silent) {
      setLoading(true);
    }
    try {
      const fetched = await listSessions();
      const sorted = [...fetched].sort((a, b) => {
        const aInput = a.awaitingInput || a.phase === "Awaiting Approval";
        const bInput = b.awaitingInput || b.phase === "Awaiting Approval";
        if (aInput !== bInput) return aInput ? -1 : 1;
        const aTime = a.updatedAt ?? a.createdAt;
        const bTime = b.updatedAt ?? b.createdAt;
        return bTime.localeCompare(aTime);
      });
      const fingerprint = sorted.map(s => `${s.id}:${s.phase}:${s.updatedAt ?? s.createdAt}:${!!s.awaitingInput}:${!!s.planAvailable}`).join(",");
      if (fingerprint !== lastFingerprintRef.current) {
        lastFingerprintRef.current = fingerprint;
        setSessions(sorted);
        onSessionsChangedRef.current?.(sorted);
        if (selectedIdRef.current !== null) {
          const match = sorted.find((s) => s.id === selectedIdRef.current);
          if (match) {
            onSelectedSessionUpdatedRef.current?.(match);
          }
        }
      }
      setError(null);
    } catch (e) {
      if (!silent) {
        setError(String(e));
      }
    } finally {
      inflightRef.current = false;
      if (!silent) {
        setLoading(false);
      }
    }
  }, []);

  useEffect(() => {
    void load();
  }, [load]);

  useEffect(() => {
    const doSilentLoad = () => {
      if (document.visibilityState === "visible") {
        void load(true);
      }
    };
    const interval = setInterval(doSilentLoad, 3000);
    document.addEventListener("visibilitychange", doSilentLoad);
    return () => {
      clearInterval(interval);
      document.removeEventListener("visibilitychange", doSilentLoad);
    };
  }, [load]);

  useEffect(() => {
    if (onRefreshRef) {
      onRefreshRef.current = () => void load(true);
      return () => { onRefreshRef.current = null; };
    }
  }, [load, onRefreshRef]);

  useEffect(() => {
    let updateIntervalId: ReturnType<typeof setInterval>;

    void getVersion().then(setVersion).catch(() => {});

    void getUpdateReadiness().then(setUpdateReadiness).catch(() => {});

    const doCheck = () => {
      void checkForUpdate().then((u) => { if (u) setUpdate(u); });
    };

    const updateTimerId = setTimeout(() => {
      void getUpdateReadiness().then(setUpdateReadiness).catch(() => {});
      doCheck();
      updateIntervalId = setInterval(doCheck, 24 * 60 * 60 * 1000);
    }, 2000);

    return () => {
      clearTimeout(updateTimerId);
      clearInterval(updateIntervalId);
    };
  }, []);

  async function handleCheckUpdates() {
    setManualCheck("checking");
    try {
      const u = await checkForUpdateManual();
      if (u) {
        setUpdate(u);
        setManualCheck("idle");
      } else {
        setManualCheck("upToDate");
      }
    } catch (e) {
      setManualCheck({ error: String(e) });
    }
  }

  function handleDismissError() {
    setUpdate(null);
    setUpdateState("available");
    setErrorMsg("");
    setManualCheck("idle");
  }

  async function handleInstall() {
    setUpdateState("downloading");
    try {
      await downloadAndInstall(update!);
    } catch (e) {
      setUpdateState("error");
      setErrorMsg(String(e));
    }
  }

  async function handleClean() {
    setCleaning(true);
    setCleanMessage(null);
    try {
      const result = await cleanSessions();
      setCleanMessage(`${result.deleted} deleted (skipped: ${result.skipped})`);
      void load(true);
    } catch (e) {
      setCleanMessage(`Error: ${e}`);
    } finally {
      setCleaning(false);
    }
  }

  const showAutoUpdate = update && updateState === "available" && updateReadiness?.canAutoUpdate;
  const updateGuidance = updateReadiness && !updateReadiness.canAutoUpdate ? updateReadiness.guidance : null;

  return (
    <div className="h-full flex flex-col">
      <div className="px-3 py-3 border-b border-gray-800 space-y-1.5">
        <div className="flex items-center justify-between gap-2">
          <h2 className="text-sm font-semibold text-gray-200">Sessions</h2>
          <div className="flex items-center gap-1">
            <button
              type="button"
              onClick={() => void handleClean()}
              disabled={cleaning}
              className="px-2 py-1 text-xs text-gray-400 hover:text-gray-200 hover:bg-gray-800 rounded disabled:opacity-50 flex items-center gap-1"
              title="Clean completed sessions"
            >
              {cleaning ? (
                <>
                  <Spinner />
                  Cleaning...
                </>
              ) : (
                "Clean"
              )}
            </button>
            <button
              type="button"
              onClick={onRunAll}
              disabled={!runAllActive && !sessions.some((s) => s.phase === "Planned" || s.phase === "Suspended" || isApprovalReady(s))}
              className={`px-2 py-1 text-xs rounded ${
                runAllActive
                  ? "bg-blue-600 text-white hover:bg-blue-700"
                  : "text-gray-400 hover:text-gray-200 hover:bg-gray-800 disabled:opacity-50"
              }`}
              aria-label={runAllActive ? "View running sessions" : "Run all pending sessions"}
              title={runAllActive ? "View running sessions" : "Run all pending sessions"}
            >
              Run All
            </button>
            <button
              type="button"
              onClick={onNewSession}
              className="px-2 py-1 text-xs bg-blue-600 text-white hover:bg-blue-700 rounded"
            >
              + New
            </button>
            {onSettings && (
              <button
                type="button"
                onClick={onSettings}
                aria-label="Settings"
                title="Settings"
                className="px-2 py-1 text-xs text-gray-400 hover:text-gray-200 hover:bg-gray-800 rounded"
              >
                {'\u2699'}
              </button>
            )}
          </div>
        </div>
        {cleanMessage && (
          <p className="text-xs text-gray-400">{cleanMessage}</p>
        )}
      </div>

      <div className="flex-1 overflow-y-auto">
        {loading && (
          <p className="p-3 text-xs text-gray-500">Loading...</p>
        )}
        {error && (
          <p className="p-3 text-xs text-red-400">Error: {error}</p>
        )}
        {!loading && !error && sessions.length === 0 && (
          <p className="p-3 text-xs text-gray-500">No sessions found.</p>
        )}
        {sessions.map((s) => (
          <button
            key={s.id}
            type="button"
            onClick={() => onSelect(s)}
            className={`w-full text-left px-3 py-2.5 border-b border-gray-800/50 hover:bg-gray-800 transition-colors ${
              selectedId === s.id ? "bg-gray-800" : ""
            }`}
          >
            <div className="flex items-center justify-between gap-2 mb-0.5">
              <span className="text-xs text-gray-500 font-mono truncate">{s.id}</span>
              <PhaseBadge phase={s.phase} planAvailable={s.planAvailable} fixing={fixingSessionIds?.has(s.id)} />
            </div>
            <p className="text-sm text-gray-300 truncate">{s.title || s.input}</p>
            {s.title && (
              <p className="text-xs text-gray-500 truncate">{s.input}</p>
            )}
            <div className="flex items-center gap-1.5 mt-0.5">
              <span className="text-xs text-blue-400/70 font-mono truncate">
                {s.baseDir.replace(/\\/g, "/").split("/").filter(Boolean).at(-1) ?? s.baseDir}
              </span>
              <span className="text-xs text-gray-600">{formatLocalTime(s.updatedAt ?? s.createdAt)}</span>
            </div>
          </button>
        ))}
      </div>

      {/* Sidebar footer: version & update */}
      <div className="flex-shrink-0 border-t border-gray-800 px-3 py-2">
        <div className="text-xs text-gray-500">{version ? `v${version}` : "..."}</div>
        {showAutoUpdate && (
          <div className="mt-1 space-y-1">
            <div className="text-xs text-green-400">v{update.version} available</div>
            <button
              type="button"
              onClick={() => void handleInstall()}
              className="px-2 py-0.5 bg-blue-600 text-white rounded text-xs hover:bg-blue-700"
            >
              Update
            </button>
          </div>
        )}
        {updateGuidance && (
          <div className="mt-1 text-xs text-yellow-400">{updateGuidance}</div>
        )}
        {updateState === "downloading" && (
          <div className="mt-1 text-xs text-gray-400">Downloading...</div>
        )}
        {updateState === "error" && (
          <div className="mt-1 space-y-1">
            <div className="text-xs text-red-400">{errorMsg}</div>
            <button
              type="button"
              onClick={handleDismissError}
              className="px-2 py-0.5 border border-gray-700 text-gray-400 rounded text-xs hover:bg-gray-800"
            >
              Dismiss
            </button>
          </div>
        )}
        <div className="mt-1">
          {manualCheck === "checking" ? (
            <div className="text-xs text-gray-400">Checking...</div>
          ) : (
            <button
              type="button"
              onClick={() => void handleCheckUpdates()}
              className="px-2 py-0.5 border border-gray-700 text-gray-400 rounded text-xs hover:bg-gray-800"
            >
              Check Updates
            </button>
          )}
          {manualCheck === "upToDate" && !update && (
            <div className="mt-0.5 text-xs text-gray-400">Up to date</div>
          )}
          {typeof manualCheck === "object" && (
            <div className="mt-0.5 space-y-1">
              <div className="text-xs text-red-400">{manualCheck.error}</div>
              <button
                type="button"
                onClick={() => setManualCheck("idle")}
                className="px-2 py-0.5 border border-gray-700 text-gray-400 rounded text-xs hover:bg-gray-800"
              >
                Dismiss
              </button>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}