794b240156
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
230 lines
8.4 KiB
TypeScript
230 lines
8.4 KiB
TypeScript
/**
|
|
* @file DestructiveLaneModal.tsx
|
|
* @description Fetches and displays the exact lane lifecycle preflight facts
|
|
* before delegating confirmation controls and accessibility to ConfirmModal.
|
|
*
|
|
* The server is the authority on what is permitted; this modal must never be
|
|
* stricter than it. A blocker that does not actually stop the chosen action is
|
|
* rendered as context (see HARD_BLOCKERS and noticesFor), not as a refusal —
|
|
* treating every blocker as fatal is what left "Forget" dead for adopted lanes.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ConfirmModal } from "../ConfirmModal";
|
|
import { api } from "../../lib/api";
|
|
import type { Lane, LanePreflight } from "../../lib/types";
|
|
|
|
type DestructiveAction = "reset" | "remove" | "purge";
|
|
|
|
export interface DestructiveLaneModalProps {
|
|
lane: Lane;
|
|
action: DestructiveAction;
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onConfirm: (body: { expect: Record<string, string | number | null>; force?: true }) => void;
|
|
}
|
|
|
|
function expectFor(preflight: LanePreflight): Record<string, string | number | null> {
|
|
if (preflight.action === "purge") {
|
|
return {
|
|
sessions: preflight.sessions,
|
|
events: preflight.events,
|
|
tokenRows: preflight.tokenRows,
|
|
};
|
|
}
|
|
return {
|
|
head: preflight.head,
|
|
dirty: preflight.dirty,
|
|
untracked: preflight.untracked,
|
|
unpushed: preflight.unpushed,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Which preflight blockers genuinely prevent each action in the UI.
|
|
*
|
|
* The server is the authority, and it permits EVERY shape of `remove`: an
|
|
* adopted lane's record is forgotten with its directory untouched, a
|
|
* hand-deleted worktree takes the prune path, an unreadable one is force
|
|
* -removed. So `remove` is blocked here by nothing — treating `adopted` or
|
|
* `missing` as blockers left the Forget button permanently dead with no
|
|
* fallback. `reset` really is impossible in all three shapes.
|
|
*
|
|
* `unpushed-commits` is force-overridable and is gated by the Force checkbox
|
|
* instead of by this list.
|
|
*/
|
|
const HARD_BLOCKERS: Record<"reset" | "remove", readonly string[]> = {
|
|
reset: ["adopted", "missing", "unreadable"],
|
|
remove: [],
|
|
};
|
|
|
|
function blockingReason(preflight: LanePreflight | null): string | null {
|
|
if (!preflight || preflight.action === "purge") return null;
|
|
const hard = HARD_BLOCKERS[preflight.action];
|
|
return preflight.blocked.find((blocker) => hard.includes(blocker)) || null;
|
|
}
|
|
|
|
/**
|
|
* Blockers that do not prevent THIS action but still change what it does, shown
|
|
* as context rather than as an obstacle — e.g. forgetting an adopted lane leaves
|
|
* its directory alone. `unpushed-commits` is excluded: the Force copy covers it.
|
|
*/
|
|
function noticesFor(preflight: LanePreflight | null): string[] {
|
|
if (!preflight || preflight.action === "purge") return [];
|
|
const hard = HARD_BLOCKERS[preflight.action];
|
|
return preflight.blocked.filter(
|
|
(blocker) => blocker !== "unpushed-commits" && !hard.includes(blocker)
|
|
);
|
|
}
|
|
|
|
/** Rough byte estimate as a short human string; the server's own number is a guess. */
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
function warningsFor(preflight: LanePreflight | null): string[] {
|
|
if (!preflight || preflight.action === "purge") return [];
|
|
return preflight.warnings;
|
|
}
|
|
|
|
export function DestructiveLaneModal({
|
|
lane,
|
|
action,
|
|
open,
|
|
onClose,
|
|
onConfirm,
|
|
}: DestructiveLaneModalProps) {
|
|
const { t } = useTranslation(["lanes"]);
|
|
const [preflight, setPreflight] = useState<LanePreflight | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [force, setForce] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
let current = true;
|
|
setLoading(true);
|
|
setError(null);
|
|
setPreflight(null);
|
|
setForce(false);
|
|
void api.lanes
|
|
.preflight(lane.id, action)
|
|
.then((result) => current && setPreflight(result))
|
|
.catch((err) => current && setError(err instanceof Error ? err.message : t("preflightError")))
|
|
.finally(() => current && setLoading(false));
|
|
return () => {
|
|
current = false;
|
|
};
|
|
}, [action, lane.id, open, t]);
|
|
|
|
const blocked = blockingReason(preflight);
|
|
// Mirrors the server's gate exactly (server/routes/lanes.js): force is required
|
|
// for `reset`, and for `remove` only when a real worktree is at risk. Forgetting
|
|
// an adopted lane risks nothing on disk, so it needs no force even with unpushed
|
|
// commits. Never offered alongside a hard blocker, where confirming is
|
|
// impossible anyway.
|
|
const requiresForce =
|
|
!blocked &&
|
|
preflight !== null &&
|
|
preflight.action !== "purge" &&
|
|
preflight.blocked.includes("unpushed-commits") &&
|
|
(preflight.action === "reset" || preflight.kind === "managed");
|
|
const disabled =
|
|
loading || !preflight || Boolean(error) || Boolean(blocked) || (requiresForce && !force);
|
|
const facts = preflight ? Object.entries(expectFor(preflight)) : [];
|
|
const purge = preflight?.action === "purge" ? preflight : null;
|
|
|
|
return (
|
|
<ConfirmModal
|
|
open={open}
|
|
title={t(`destructive.${action}.title`)}
|
|
message={t(`destructive.${action}.message`)}
|
|
confirmLabel={t(`destructive.${action}.confirm`)}
|
|
cancelLabel={t("destructive.cancel")}
|
|
busy={loading}
|
|
disabled={disabled}
|
|
onCancel={onClose}
|
|
onConfirm={() => {
|
|
if (!preflight || disabled) return;
|
|
onConfirm({ expect: expectFor(preflight), ...(force ? { force: true as const } : {}) });
|
|
}}
|
|
>
|
|
{loading && <p className="mt-3 text-xs text-neutral-400">{t("destructive.loading")}</p>}
|
|
{error && (
|
|
<p className="mt-3 text-xs text-red-300">
|
|
{t("preflightErrorWithMessage", { message: error })}
|
|
</p>
|
|
)}
|
|
{preflight && (
|
|
<table className="mt-3 w-full text-left text-xs text-neutral-300">
|
|
<tbody>
|
|
{facts.map(([name, value]) => (
|
|
<tr key={name} className="border-t border-neutral-800">
|
|
<th scope="row" className="py-1.5 font-medium text-neutral-400">
|
|
{t(`destructive.count.${name}`)}
|
|
</th>
|
|
<td className="py-1.5 text-right tabular-nums">{value ?? "—"}</td>
|
|
</tr>
|
|
))}
|
|
{/* Not part of `expect`: an estimate the server never verifies. */}
|
|
{purge && (
|
|
<tr className="border-t border-neutral-800">
|
|
<th scope="row" className="py-1.5 font-medium text-neutral-400">
|
|
{t("destructive.count.bytesEstimate")}
|
|
</th>
|
|
<td className="py-1.5 text-right tabular-nums">
|
|
{formatBytes(purge.bytesEstimate)}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
{blocked && (
|
|
<p className="mt-3 rounded border border-amber-700/60 bg-amber-950/30 px-2 py-1.5 text-xs text-amber-200">
|
|
{t(`destructive.blocked.${blocked}`)}
|
|
</p>
|
|
)}
|
|
{purge?.activeSessionSkipped && (
|
|
<p className="mt-3 rounded border border-neutral-700 bg-neutral-800/50 px-2 py-1.5 text-xs text-neutral-300">
|
|
{t("destructive.notice.activeSessionSkipped")}
|
|
</p>
|
|
)}
|
|
{noticesFor(preflight).map((notice) => (
|
|
<p
|
|
key={notice}
|
|
className="mt-3 rounded border border-neutral-700 bg-neutral-800/50 px-2 py-1.5 text-xs text-neutral-300"
|
|
>
|
|
{t(`destructive.notice.${notice}`)}
|
|
</p>
|
|
))}
|
|
{warningsFor(preflight).map((warning) => (
|
|
<p
|
|
key={warning}
|
|
className="mt-3 rounded border border-neutral-700 bg-neutral-800/50 px-2 py-1.5 text-xs text-neutral-300"
|
|
>
|
|
{t(`destructive.warning.${warning}`)}
|
|
</p>
|
|
))}
|
|
{requiresForce && (
|
|
<label className="mt-3 flex items-start gap-2 text-xs text-amber-200">
|
|
<input
|
|
type="checkbox"
|
|
checked={force}
|
|
onChange={(event) => setForce(event.target.checked)}
|
|
className="mt-0.5"
|
|
/>
|
|
{t("destructive.force")}
|
|
</label>
|
|
)}
|
|
{action === "reset" && (
|
|
<p className="mt-3 text-xs text-neutral-400">{t("destructive.reset.survives")}</p>
|
|
)}
|
|
</ConfirmModal>
|
|
);
|
|
}
|