cruise 0.1.45

YAML-driven coding agent workflow orchestrator
Documentation
import { useEffect, useId, useRef, useState } from "react";
import { listGithubRepos } from "../lib/commands";

interface RepoPickerProps {
  value: string;
  onChange: (value: string) => void;
  disabled?: boolean;
  placeholder?: string;
  id?: string;
}

function filterRepos(all: string[], query: string): string[] {
  const q = query.trim().toLowerCase();
  if (!q) return all;
  return all.filter((repo) => repo.toLowerCase().includes(q));
}

/**
 * Combobox for picking a GitHub repository (`owner/repo`).
 *
 * Suggestions come from `gh repo list` (loaded once on mount); free-form
 * input is always accepted so repositories outside the authenticated user's
 * account can be used too.
 */
export function RepoPicker({
  value,
  onChange,
  disabled = false,
  placeholder,
  id,
}: RepoPickerProps) {
  const [repos, setRepos] = useState<string[]>([]);
  const [loadError, setLoadError] = useState<string | null>(null);
  const [isOpen, setIsOpen] = useState(false);
  const [highlighted, setHighlighted] = useState<number>(-1);

  const uid = useId();
  const listboxId = `${uid}-listbox`;
  const containerRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    let active = true;
    listGithubRepos()
      .then((result) => {
        if (active) {
          setRepos(result);
          setLoadError(null);
        }
      })
      .catch((e) => {
        if (active) setLoadError(String(e));
      });
    return () => {
      active = false;
    };
  }, []);

  // Close dropdown when clicking outside
  useEffect(() => {
    function handleMouseDown(e: MouseEvent) {
      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
        setIsOpen(false);
      }
    }
    document.addEventListener("mousedown", handleMouseDown);
    return () => document.removeEventListener("mousedown", handleMouseDown);
  }, []);

  const filtered = filterRepos(repos, value);

  function selectRepo(repo: string) {
    onChange(repo);
    setIsOpen(false);
    setHighlighted(-1);
    inputRef.current?.focus();
  }

  function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
    if (!isOpen) return;

    if (e.key === "ArrowDown") {
      e.preventDefault();
      setHighlighted((h) => Math.min(h + 1, filtered.length - 1));
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setHighlighted((h) => Math.max(h - 1, 0));
    } else if (e.key === "Enter") {
      if (highlighted >= 0 && highlighted < filtered.length) {
        e.preventDefault();
        selectRepo(filtered[highlighted]);
      }
    } else if (e.key === "Escape") {
      setIsOpen(false);
      setHighlighted(-1);
    }
  }

  return (
    <div ref={containerRef} className="relative">
      <input
        ref={inputRef}
        id={id}
        type="text"
        role="combobox"
        aria-expanded={isOpen}
        aria-autocomplete="list"
        aria-controls={listboxId}
        aria-activedescendant={highlighted >= 0 ? `${listboxId}-opt-${highlighted}` : undefined}
        value={value}
        onChange={(e) => {
          onChange(e.target.value);
          setIsOpen(true);
          setHighlighted(-1);
        }}
        onFocus={() => {
          if (filtered.length > 0) setIsOpen(true);
        }}
        onKeyDown={handleKeyDown}
        disabled={disabled}
        placeholder={placeholder ?? "owner/repository"}
        className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-blue-500 outline-none disabled:opacity-50"
      />

      {loadError && (
        <p className="mt-1 text-xs text-yellow-500">
          Could not list repositories via gh ({loadError}); type owner/repository manually.
        </p>
      )}

      {isOpen && filtered.length > 0 && (
        <ul
          id={listboxId}
          role="listbox"
          className="absolute z-50 top-full left-0 right-0 mt-1 bg-gray-800 border border-gray-700 rounded shadow-lg max-h-56 overflow-auto"
        >
          {filtered.map((repo, i) => (
            <li
              key={repo}
              id={`${listboxId}-opt-${i}`}
              role="option"
              aria-selected={i === highlighted}
              onMouseDown={(e) => {
                e.preventDefault();
                selectRepo(repo);
              }}
              onMouseEnter={() => setHighlighted(i)}
              className={`px-3 py-1.5 text-sm text-gray-200 cursor-pointer ${
                i === highlighted ? "bg-gray-700" : "hover:bg-gray-800"
              }`}
            >
              {repo}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}