Repository URL to install this package:
|
Version:
0.4.46 ▾
|
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