fcfebe44f1
Settings → Dashboard gains an AI Tools section (installed tools with Enabled/Disabled and Set default, available ones with a homepage link, Refresh). useAiTools shares one catalog copy across the app and replaces LANE_TOOL_LABEL. The Add-lane modal picks Default (<tool>) or Other…; the router row reads Native (<tool>) and disables AI Router profiles for tools that cannot use one. The run form gives catalog tools resume and a red per-run Bypass permissions switch (Codex too). Claude Code / Codex settings tabs hide while that tool is disabled. The Workspace probes each lane tool's CLI fresh. Docs: CLAUDE.md tool/bypass rule, AGENTS.md, LANES.md, API.md, CLI.md, server/README.md, OpenAPI fragment + regenerated openapi.yaml.
153 lines
6.0 KiB
TypeScript
153 lines
6.0 KiB
TypeScript
/**
|
|
* @file The compact lane tile used in the Workspace's horizontal lane strip.
|
|
* It carries only what you need to pick a lane — which lane, is it alive,
|
|
* what stage — because the full card, its controls and its working-copy facts
|
|
* live in the detail panel below it. Stage badges ride on the id row rather
|
|
* than a line of their own, and progress (bar and percent) is deliberately
|
|
* absent: the detail panel's pipeline map below shows the same thing with the
|
|
* stage names attached. Keeping the tile small and fixed-width is what lets
|
|
* many lanes stay scannable in one scrolling row.
|
|
*
|
|
* The tile is also the strip's reorder handle: native HTML5 drag-and-drop, no
|
|
* dependency. Ceiling: desktop pointers only — there is no touch drag and no
|
|
* auto-scroll when the strip overflows, so Alt+Arrow moves the focused tile as
|
|
* the keyboard (and touch-free) path.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { Lane } from "../../lib/types";
|
|
import { useAiTools } from "../../hooks/useAiTools";
|
|
|
|
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
|
active: "bg-status-success",
|
|
idle: "bg-surface-4",
|
|
dead: "bg-status-danger",
|
|
};
|
|
|
|
/**
|
|
* Whether the inferred stage sits ahead of the declared one in node order.
|
|
*
|
|
* The declaration is located by the node the server resolved it onto (`state
|
|
* === "current"`), not by `indexOf(lane.stage)`: a stage declared under an
|
|
* ALIAS (`tests`, `booting`, `ship` — most of what the ship-feature skill
|
|
* declares) is not a node id, so indexOf answers -1 and a detection sitting
|
|
* well BEHIND the declaration would still be badged as the live one.
|
|
*
|
|
* `>=`, not `>`: an undeclared lane is rendered AT its detected node by the
|
|
* server (see `inferredDisplayStage`), so the detection and the "current" node
|
|
* are the same one — and that lane is precisely the one whose stage the badge
|
|
* is the only readout for. A declared lane can never tie, because
|
|
* recordDetection refuses any detection that isn't strictly ahead.
|
|
*/
|
|
function detectionLeads(lane: Lane): boolean {
|
|
if (!lane.detected_stage || !lane.pipeline_nodes.length) return false;
|
|
const nodes = lane.pipeline_nodes;
|
|
return (
|
|
nodes.findIndex((n) => n.id === lane.detected_stage) >=
|
|
nodes.findIndex((n) => n.state === "current")
|
|
);
|
|
}
|
|
|
|
export default function LaneStripCard({
|
|
lane,
|
|
index,
|
|
selected,
|
|
onSelect,
|
|
onReorder,
|
|
}: {
|
|
lane: Lane;
|
|
/** Position in the lane array — the unit both reorder paths speak in. */
|
|
index: number;
|
|
selected: boolean;
|
|
onSelect: () => void;
|
|
onReorder: (from: number, to: number) => void;
|
|
}) {
|
|
const { t } = useTranslation(["lanes"]);
|
|
const { label: toolLabel } = useAiTools();
|
|
const [dropTarget, setDropTarget] = useState(false);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-testid={`lane-tile-${lane.id}`}
|
|
data-selected={selected ? "true" : undefined}
|
|
data-drop-target={dropTarget ? "true" : undefined}
|
|
aria-pressed={selected}
|
|
onClick={onSelect}
|
|
draggable
|
|
onDragStart={(e) => {
|
|
// Firefox starts no drag at all without a setData call.
|
|
e.dataTransfer.setData("text/plain", String(index));
|
|
e.dataTransfer.effectAllowed = "move";
|
|
}}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
setDropTarget(true);
|
|
}}
|
|
onDragLeave={() => setDropTarget(false)}
|
|
onDragEnd={() => setDropTarget(false)}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
setDropTarget(false);
|
|
const from = Number(e.dataTransfer.getData("text/plain"));
|
|
if (Number.isInteger(from)) onReorder(from, index);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
|
|
e.preventDefault();
|
|
onReorder(index, index + (e.key === "ArrowLeft" ? -1 : 1));
|
|
}}
|
|
title={`${lane.cwd}\n${t("reorderHint")}`}
|
|
className={`w-56 shrink-0 cursor-grab rounded-lg border p-3 text-left shadow-sm transition-colors active:cursor-grabbing ${
|
|
dropTarget
|
|
? "border-accent bg-accent/20"
|
|
: selected
|
|
? "border-accent bg-accent/10"
|
|
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
|
}`}
|
|
>
|
|
<div className="mb-1.5 flex items-center gap-1.5 text-[11px]">
|
|
<span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
|
{/* The bare id, not "Lane 23": the word cost a third of the row's
|
|
width and said nothing, but the number itself is how the CLI
|
|
addresses a lane (`agenthub stage --lane 23`), so it stays. */}
|
|
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
|
|
#{lane.id}
|
|
</span>
|
|
{/* Only claude lanes carry a pipeline, and only while a template is
|
|
installed; the others show their CLI's tag in place of the stage chip. */}
|
|
{lane.tool !== "claude" || lane.pipeline_nodes.length === 0 ? (
|
|
<span className="shrink-0 rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
|
|
{toolLabel(lane.tool)}
|
|
</span>
|
|
) : (
|
|
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
|
{lane.stage}
|
|
</span>
|
|
)}
|
|
{lane.tool === "claude" && detectionLeads(lane) && (
|
|
<span
|
|
data-testid={`lane-tile-auto-${lane.id}`}
|
|
title={lane.detected_signal || undefined}
|
|
className="shrink-0 rounded border border-dashed border-status-warning/50 px-1 text-status-warning"
|
|
>
|
|
{lane.detected_stage}
|
|
</span>
|
|
)}
|
|
{lane.needs_action && (
|
|
<span className="ml-auto shrink-0 text-status-warning" title={lane.needs_action}>
|
|
⚠
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="truncate text-[13px] font-medium text-fg-primary">
|
|
{lane.title || lane.cwd}
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|