Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
omni-code / tui / dist / views / Board.js
Size: Mime:
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Box, Text, useInput, useStdout } from "ink";
import { api } from "../rpc.js";
import { Panel } from "../components/Panel.js";
import { PhaseBadge, PriorityBadge, TaskStatusBadge } from "../components/StatusBadge.js";
import { TextInput } from "../components/TextInput.js";
import { HelpOverlay } from "../components/HelpOverlay.js";
import { ConfirmDialog } from "../components/ConfirmDialog.js";
import { FilterInput, fuzzyMatch } from "../components/FilterInput.js";
function clamp(value, max) {
    if (max <= 0)
        return 0;
    return Math.max(0, Math.min(value, max - 1));
}
const HELP_HINTS = [
    { key: "h/l", label: "switch column" },
    { key: "j/k", label: "navigate tickets" },
    { key: "enter", label: "open ticket detail" },
    { key: "a", label: "add new ticket" },
    { key: "m", label: "move ticket to column" },
    { key: "s", label: "start supervisor" },
    { key: "X", label: "stop supervisor" },
    { key: "H", label: "SSH into container" },
    { key: "o", label: "launch omni agent" },
    { key: "c", label: "resume chat" },
    { key: "x", label: "close panes" },
    { key: "d", label: "delete ticket" },
    { key: "D", label: "toggle auto-dispatch" },
    { key: "/", label: "filter tickets" },
    { key: "r", label: "refresh" },
    { key: "?", label: "help" },
    { key: "esc", label: "back" },
];
const BAR_HINTS = [
    { key: "h/l", label: "column" },
    { key: "j/k", label: "ticket" },
    { key: "enter", label: "detail" },
    { key: "a", label: "add" },
    { key: "m", label: "move" },
    { key: "s", label: "start" },
    { key: "o", label: "omni" },
    { key: "d", label: "delete" },
    { key: "/", label: "filter" },
    { key: "?", label: "help" },
    { key: "esc", label: "back" },
];
export function Board({ project, onSelectTicket, onBack, onQuit, onInputActive, onHints, onStatus }) {
    const { stdout } = useStdout();
    const rows = stdout?.rows ?? 24;
    const cols = stdout?.columns ?? 80;
    const [columns, setColumns] = useState([]);
    const [tickets, setTickets] = useState([]);
    const [colIndex, setColIndex] = useState(0);
    const [rowIndex, setRowIndex] = useState(0);
    const [status, setStatus] = useState(null);
    const [adding, setAdding] = useState(false);
    const [addTitle, setAddTitle] = useState("");
    const [addDescription, setAddDescription] = useState("");
    const [columnPicker, setColumnPicker] = useState(null);
    const [showHelp, setShowHelp] = useState(false);
    const [confirmAction, setConfirmAction] = useState(null);
    const [filterQuery, setFilterQuery] = useState("");
    const [filtering, setFiltering] = useState(false);
    const [autoDispatch, setAutoDispatch] = useState(false);
    const [ticketPanes, setTicketPanes] = useState({});
    const refresh = useCallback(async () => {
        try {
            const [pipelineResult, ticketList] = await Promise.all([
                api.getPipeline(project.id),
                api.listTickets(project.id),
            ]);
            setColumns(pipelineResult.columns);
            setTickets(ticketList);
            api.getAutoDispatch(project.id).then((r) => setAutoDispatch(r.enabled)).catch(() => { });
            api.tmuxListPanes().then((panes) => {
                const map = {};
                for (const p of panes) {
                    if (!map[p.ticket_id])
                        map[p.ticket_id] = [];
                    map[p.ticket_id].push({ pane_type: p.pane_type });
                }
                setTicketPanes(map);
            }).catch(() => { });
        }
        catch (err) {
            setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`);
        }
    }, [project.id]);
    useEffect(() => {
        refresh();
        const interval = setInterval(refresh, 5000);
        return () => clearInterval(interval);
    }, [refresh]);
    useEffect(() => {
        if (!status)
            return;
        const timer = setTimeout(() => setStatus(null), 4000);
        return () => clearTimeout(timer);
    }, [status]);
    // Report status/hints to parent for app-level StatusBar
    useEffect(() => { onStatus?.(status ?? null); }, [status]);
    useEffect(() => {
        onHints?.([
            ...BAR_HINTS,
            ...(autoDispatch ? [{ key: "D", label: "auto\u2713" }] : [{ key: "D", label: "auto" }]),
            ...(filterQuery ? [{ key: "filter", label: filterQuery }] : []),
        ]);
    }, [autoDispatch, filterQuery]);
    const ticketsByColumn = useMemo(() => {
        const map = new Map();
        for (const col of columns) {
            map.set(col.id, []);
        }
        for (const ticket of tickets) {
            if (!fuzzyMatch(ticket.title, filterQuery))
                continue;
            const bucket = map.get(ticket.column_id);
            if (bucket)
                bucket.push(ticket);
        }
        return map;
    }, [columns, tickets, filterQuery]);
    const currentColumnTickets = useMemo(() => {
        const col = columns[colIndex];
        return col ? ticketsByColumn.get(col.id) ?? [] : [];
    }, [columns, colIndex, ticketsByColumn]);
    const selectedTicket = currentColumnTickets[rowIndex] ?? null;
    useEffect(() => {
        setRowIndex(0);
    }, [colIndex]);
    useInput((input, key) => {
        if (adding || showHelp || confirmAction || filtering)
            return;
        // Column picker mode
        if (columnPicker) {
            if (key.escape) {
                setColumnPicker(null);
                return;
            }
            if (key.upArrow || input === "k") {
                setColumnPicker((prev) => prev ? { ...prev, selectedIndex: clamp(prev.selectedIndex - 1, prev.columns.length) } : null);
                return;
            }
            if (key.downArrow || input === "j") {
                setColumnPicker((prev) => prev ? { ...prev, selectedIndex: clamp(prev.selectedIndex + 1, prev.columns.length) } : null);
                return;
            }
            if (key.return) {
                const targetCol = columnPicker.columns[columnPicker.selectedIndex];
                if (targetCol && targetCol.id !== columnPicker.ticket.column_id) {
                    api.moveTicket(columnPicker.ticket.id, targetCol.id, project.id)
                        .then(() => { setStatus(`Moved to ${targetCol.label}`); setColumnPicker(null); refresh(); })
                        .catch((err) => { setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`); setColumnPicker(null); });
                }
                else {
                    setColumnPicker(null);
                }
                return;
            }
            return;
        }
        if (input === "?") {
            setShowHelp(true);
            return;
        }
        if (input === "/") {
            setFiltering(true);
            setFilterQuery("");
            onInputActive?.(true);
            return;
        }
        if (key.escape) {
            onBack();
            return;
        }
        if (input === "q") {
            onQuit();
            return;
        }
        if (input === "r") {
            refresh();
            setStatus("Refreshed");
            return;
        }
        // Navigation
        if (key.leftArrow || input === "h") {
            setColIndex((v) => clamp(v - 1, columns.length));
            return;
        }
        if (key.rightArrow || input === "l") {
            setColIndex((v) => clamp(v + 1, columns.length));
            return;
        }
        if (key.upArrow || input === "k") {
            setRowIndex((v) => clamp(v - 1, currentColumnTickets.length));
            return;
        }
        if (key.downArrow || input === "j") {
            setRowIndex((v) => clamp(v + 1, currentColumnTickets.length));
            return;
        }
        // Actions
        if (key.return && selectedTicket) {
            onSelectTicket(selectedTicket);
            return;
        }
        if (input === "a") {
            setAdding("title");
            setAddTitle("");
            setAddDescription("");
            onInputActive?.(true);
            return;
        }
        if (input === "m" && selectedTicket) {
            setColumnPicker({ ticket: selectedTicket, columns, selectedIndex: columns.findIndex((c) => c.id === selectedTicket.column_id) });
            return;
        }
        if (input === "s" && selectedTicket) {
            setStatus("Starting supervisor...");
            api.sandboxStart(selectedTicket.id, project.id)
                .then(() => api.supervisorStart(selectedTicket.id, undefined, project.id))
                .then(() => { setStatus(`Started supervisor`); refresh(); })
                .catch((err) => setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`));
            return;
        }
        if (input === "X" && selectedTicket) {
            const ticket = selectedTicket;
            setConfirmAction({
                message: `Stop supervisor for "${ticket.title}"?`,
                action: () => {
                    api.supervisorStop(ticket.id, project.id)
                        .then(() => { setStatus(`Stopped supervisor`); refresh(); })
                        .catch((err) => setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`));
                },
            });
            return;
        }
        if (input === "H" && selectedTicket) {
            setStatus("Opening SSH...");
            api.tmuxSsh(selectedTicket.id, project.id)
                .then((r) => { setStatus(r.reused ? "Focused SSH pane" : "SSH pane opened"); refresh(); })
                .catch((err) => setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`));
            return;
        }
        if (input === "o" && selectedTicket) {
            setStatus("Opening omni...");
            api.tmuxOmni(selectedTicket.id, project.id)
                .then((r) => { setStatus(r.reused ? "Focused omni pane" : "Omni pane opened"); refresh(); })
                .catch((err) => setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`));
            return;
        }
        if (input === "c" && selectedTicket) {
            setStatus("Opening chat...");
            api.tmuxChat(selectedTicket.id, project.id)
                .then((r) => { setStatus(r.reused ? "Focused chat pane" : "Chat pane opened"); refresh(); })
                .catch((err) => setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`));
            return;
        }
        if (input === "x" && selectedTicket) {
            api.tmuxCloseTicketPanes(selectedTicket.id)
                .then((n) => { setStatus(n > 0 ? `Closed ${n} pane${n > 1 ? "s" : ""}` : "No open panes"); refresh(); })
                .catch((err) => setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`));
            return;
        }
        if (input === "D") {
            const newValue = !autoDispatch;
            api.setAutoDispatch(project.id, newValue)
                .then(() => { setAutoDispatch(newValue); setStatus(`Auto-dispatch ${newValue ? "enabled" : "disabled"}`); })
                .catch((err) => setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`));
            return;
        }
        if (input === "d" && selectedTicket) {
            const ticket = selectedTicket;
            setConfirmAction({
                message: `Delete ticket "${ticket.title}"?`,
                action: () => {
                    api.deleteTicket(ticket.id, project.id)
                        .then(() => { setStatus(`Deleted "${ticket.title}"`); refresh(); })
                        .catch((err) => setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`));
                },
            });
            return;
        }
    });
    const handleTitleSubmit = useCallback((title) => {
        const trimmed = title.trim();
        if (!trimmed) {
            setAdding(false);
            onInputActive?.(false);
            return;
        }
        setAddTitle(trimmed);
        setAdding("description");
    }, []);
    const handleDescriptionSubmit = useCallback(async (description) => {
        const desc = description.trim();
        try {
            const newTicket = await api.addTicket(project.id, addTitle);
            if (desc)
                await api.updateTicket(newTicket.id, { description: desc }, project.id);
            setStatus(`Added "${addTitle}"`);
            setAdding(false);
            onInputActive?.(false);
            refresh();
        }
        catch (err) {
            setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`);
            setAdding(false);
            onInputActive?.(false);
        }
    }, [project.id, addTitle, refresh]);
    if (showHelp) {
        return (React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
            React.createElement(HelpOverlay, { title: project.label, hints: HELP_HINTS, onClose: () => setShowHelp(false) })));
    }
    // Calculate column width for horizontal layout
    const colCount = columns.length || 1;
    const colWidthPct = `${Math.floor(100 / colCount)}%`;
    return (React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
        React.createElement(Box, { flexDirection: "row", flexGrow: 1 }, columns.map((col, ci) => {
            const colTickets = ticketsByColumn.get(col.id) ?? [];
            const isActive = ci === colIndex;
            return (React.createElement(Panel, { key: col.id, title: `${col.label} (${colTickets.length})${col.gate ? " G" : ""}`, focused: isActive, flexGrow: 1 },
                React.createElement(Box, { flexDirection: "column" }, colTickets.length === 0 ? (React.createElement(Text, { color: "gray" }, " --")) : (colTickets.map((ticket, ti) => {
                    const isSelected = isActive && ti === rowIndex;
                    const panes = ticketPanes[ticket.id];
                    return (React.createElement(Box, { key: ticket.id, flexDirection: "column" },
                        React.createElement(Text, { color: isSelected ? "cyan" : undefined, wrap: "truncate" },
                            isSelected ? "\u25B8 " : "  ",
                            ticket.title),
                        React.createElement(Text, { wrap: "truncate" },
                            "  ",
                            React.createElement(PriorityBadge, { priority: ticket.priority }),
                            ticket.phase ? (React.createElement(React.Fragment, null,
                                " ",
                                React.createElement(PhaseBadge, { phase: ticket.phase }))) : ticket.task ? (React.createElement(React.Fragment, null,
                                " ",
                                React.createElement(TaskStatusBadge, { status: ticket.task.status }))) : null,
                            panes?.length ? (React.createElement(Text, { color: "blue" },
                                " ",
                                panes.map((p) => p.pane_type === "chat" ? "[C]" : p.pane_type === "ssh" ? "[S]" : "[O]").join(""))) : null)));
                })))));
        })),
        columnPicker ? (React.createElement(Box, { flexDirection: "column", paddingX: 1 },
            React.createElement(Text, { color: "cyan" },
                "Move \"",
                columnPicker.ticket.title,
                "\" to:"),
            columnPicker.columns.map((col, i) => (React.createElement(Text, { key: col.id, color: i === columnPicker.selectedIndex ? "cyan" : undefined },
                i === columnPicker.selectedIndex ? "\u25B8 " : "  ",
                col.label,
                col.id === columnPicker.ticket.column_id ? " (current)" : ""))),
            React.createElement(Text, { color: "gray" }, "enter select \\u00B7 esc cancel"))) : null,
        adding === "title" ? (React.createElement(Box, { paddingX: 1 },
            React.createElement(TextInput, { value: addTitle, onChange: setAddTitle, onSubmit: handleTitleSubmit, onCancel: () => { setAdding(false); onInputActive?.(false); }, prompt: "Title:", placeholder: "ticket title" }))) : adding === "description" ? (React.createElement(Box, { paddingX: 1, flexDirection: "column" },
            React.createElement(Text, { color: "cyan" },
                "Title: ",
                addTitle),
            React.createElement(TextInput, { value: addDescription, onChange: setAddDescription, onSubmit: handleDescriptionSubmit, onCancel: () => { setAdding(false); onInputActive?.(false); }, prompt: "Desc:", placeholder: "optional (enter to skip)" }))) : null,
        confirmAction ? (React.createElement(Box, { paddingX: 1 },
            React.createElement(ConfirmDialog, { message: confirmAction.message, onConfirm: () => { confirmAction.action(); setConfirmAction(null); }, onCancel: () => setConfirmAction(null) }))) : null,
        filtering ? (React.createElement(Box, { paddingX: 1 },
            React.createElement(FilterInput, { value: filterQuery, onChange: setFilterQuery, onClose: () => { setFiltering(false); onInputActive?.(false); } }))) : null));
}
//# sourceMappingURL=Board.js.map