feat: Claude Code Monitor — lanes, pipelines and a merged workspace
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.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user