feat(FN-1465): merge fusion/fn-1465

This commit is contained in:
gsxdsm
2026-04-12 07:00:49 -07:00
parent 86f1f93de9
commit 8f0bcf7f00
58 changed files with 3890 additions and 1696 deletions

View File

@@ -46,6 +46,8 @@ interface BoardProps {
taskStuckTimeoutMs?: number;
/** Called when user clicks a mission badge on a task card */
onOpenMission?: (missionId: string) => void;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
}
function sortTasksForColumn(tasks: Task[]): Task[] {
@@ -64,7 +66,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
return previous.every((task, index) => task === next[index]);
}
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission }: BoardProps) {
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const archivedLoadedRef = useRef(false);
const { fetchBatch } = useBatchBadgeFetch(projectId);
@@ -181,6 +183,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
isSearchActive={isSearchActive}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
{...(col === "done" ? { onArchiveAllDone } : {})}

View File

@@ -15,6 +15,11 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
import { fetchModels } from "../api";
import type { ModelInfo } from "../api";
export interface ChatViewProps {
projectId?: string;
addToast: (msg: string, type?: "success" | "error") => void;
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();

View File

@@ -57,9 +57,11 @@ interface ColumnProps {
taskStuckTimeoutMs?: number;
/** Called when user clicks a mission badge on a task card */
onOpenMission?: (missionId: string) => void;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs }: ColumnProps) {
const [dragOver, setDragOver] = useState(false);
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
const countFlashing = useFlashOnIncrease(tasks.length);
@@ -232,6 +234,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
/>
))
)
@@ -254,6 +257,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
onMoveTask={onMoveTask}
lastFetchTimeMs={lastFetchTimeMs}
/>
))}
{shouldPaginate && hiddenTaskCount > 0 && (

View File

@@ -18,6 +18,8 @@ interface ExecutorStatusBarProps {
backgroundNeedsInput?: number;
onOpenBackgroundSession?: (session: AiSessionSummary) => void;
onDismissBackgroundSession?: (id: string) => void;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
}
/**
@@ -67,8 +69,8 @@ function getStateDisplay(state: ExecutorState): { label: string; color: string;
* - Executor state badge (idle/running/paused)
* - Last activity timestamp
*/
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession }: ExecutorStatusBarProps) {
const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs);
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession, lastFetchTimeMs }: ExecutorStatusBarProps) {
const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs, lastFetchTimeMs);
const stateDisplay = useMemo(() => getStateDisplay(stats.executorState), [stats.executorState]);

View File

@@ -128,6 +128,8 @@ interface ListViewProps {
taskStuckTimeoutMs?: number;
/** External search query from header search (defaults to "") */
searchQuery?: string;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
}
function getStepProgress(steps: TaskStep[]): string {
@@ -162,6 +164,7 @@ export function ListView({
projectName,
taskStuckTimeoutMs,
searchQuery = "",
lastFetchTimeMs,
}: ListViewProps) {
const [sortField, setSortField] = useState<SortField>("id");
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
@@ -804,7 +807,7 @@ export function ListView({
columnTasks.map((task) => {
const isFailed = task.status === "failed";
const isPaused = task.paused === true;
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs);
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
const isAgentActive =
!globalPaused &&
!isFailed &&
@@ -979,7 +982,7 @@ export function ListView({
columnTasks.map((task) => {
const isFailed = task.status === "failed";
const isPaused = task.paused === true;
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs);
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
const isAgentActive =
!globalPaused &&
!isFailed &&

View File

@@ -42,6 +42,14 @@ import type {
MissionHealth,
MissionEvent,
MissionEventType,
FeatureLoopState,
MissionAssertionStatus,
MissionContractAssertion,
ContractAssertionCreateInput,
ContractAssertionUpdateInput,
MilestoneValidationRollup,
MissionFeatureLoopSnapshot,
MissionValidatorRun,
} from "./mission-types";
import {
fetchMissions,
@@ -71,6 +79,21 @@ import {
fetchMissionHealth,
fetchMissionsHealth,
fetchMissionEvents,
fetchAssertions,
createAssertion,
updateAssertion,
deleteAssertion,
reorderAssertions,
linkFeatureToAssertion,
unlinkFeatureFromAssertion,
fetchAssertionsForFeature,
fetchFeaturesForAssertion,
fetchMilestoneValidation,
triggerValidation,
fetchValidationLoopState,
fetchValidationRuns,
fetchValidationRun,
fetchAssertion,
} from "../api";
import type { AutopilotStatus as AutopilotStatusType, AutopilotState } from "./mission-types";
@@ -123,6 +146,24 @@ const autopilotStateColors: Record<AutopilotState, { bg: string; text: string }>
completing: { bg: "var(--autopilot-completing-bg)", text: "var(--autopilot-completing-text)" },
};
/** Loop state colors for feature execution loop */
const loopStateColors: Record<FeatureLoopState, { bg: string; text: string; indicator: string }> = {
idle: { bg: "var(--loop-idle-bg)", text: "var(--loop-idle-text)", indicator: "var(--loop-idle-indicator)" },
implementing: { bg: "var(--loop-implementing-bg)", text: "var(--loop-implementing-text)", indicator: "var(--loop-implementing-indicator)" },
validating: { bg: "var(--loop-validating-bg)", text: "var(--loop-validating-text)", indicator: "var(--loop-validating-indicator)" },
needs_fix: { bg: "var(--loop-needs-fix-bg)", text: "var(--loop-needs-fix-text)", indicator: "var(--loop-needs-fix-indicator)" },
passed: { bg: "var(--loop-passed-bg)", text: "var(--loop-passed-text)", indicator: "var(--loop-passed-indicator)" },
blocked: { bg: "var(--loop-blocked-bg)", text: "var(--loop-blocked-text)", indicator: "var(--loop-blocked-indicator)" },
};
/** Assertion status colors */
const assertionStatusColors: Record<MissionAssertionStatus, { bg: string; text: string }> = {
pending: { bg: "var(--assertion-pending-bg)", text: "var(--assertion-pending-text)" },
passed: { bg: "var(--assertion-passed-bg)", text: "var(--assertion-passed-text)" },
failed: { bg: "var(--assertion-failed-bg)", text: "var(--assertion-failed-text)" },
blocked: { bg: "var(--assertion-blocked-bg)", text: "var(--assertion-blocked-text)" },
};
/** Get the plan state for a milestone (derived from interviewState) */
function getMilestonePlanState(interviewState?: string): "not_started" | "planned" | "needs_update" {
if (interviewState === "completed") return "planned";
@@ -424,6 +465,39 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
// Delete confirmation
const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string } | null>(null);
// Assertion panel state
const [assertionsByMilestone, setAssertionsByMilestone] = useState<Map<string, MissionContractAssertion[]>>(new Map());
const [assertionsLoading, setAssertionsLoading] = useState(false);
const [editingAssertionId, setEditingAssertionId] = useState<string | null>(null);
const [assertionForm, setAssertionForm] = useState<{ title: string; assertion: string; status: MissionAssertionStatus }>({
title: "",
assertion: "",
status: "pending",
});
const [isCreatingAssertion, setIsCreatingAssertion] = useState(false);
const [expandedAssertionId, setExpandedAssertionId] = useState<string | null>(null);
const [linkedFeaturesByAssertion, setLinkedFeaturesByAssertion] = useState<Map<string, MissionFeature[]>>(new Map());
const [linkingAssertions, setLinkingAssertions] = useState<Set<string>>(new Set());
const [unlinkingFeatures, setUnlinkingFeatures] = useState<Set<string>>(new Set());
const [featurePickerOpenForAssertion, setFeaturePickerOpenForAssertion] = useState<string | null>(null);
const [validationRollupByMilestone, setValidationRollupByMilestone] = useState<Map<string, MilestoneValidationRollup>>(new Map());
const [validatingFeatures, setValidatingFeatures] = useState<Set<string>>(new Set());
// Feature loop state
const [featureLoopStates, setFeatureLoopStates] = useState<Map<string, MissionFeatureLoopSnapshot>>(new Map());
// Expanded feature for run history display
const [expandedFeatureId, setExpandedFeatureId] = useState<string | null>(null);
// Validation runs by feature
const [validationRunsByFeature, setValidationRunsByFeature] = useState<Map<string, MissionValidatorRun[]>>(new Map());
// Expanded run ID for showing details with failures
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
// Run details with failures (keyed by runId)
const [runDetailsByRunId, setRunDetailsByRunId] = useState<Map<string, MissionValidatorRun & { failures?: Array<{ id: string; assertionId: string; message?: string; expected?: string; actual?: string }> }>>(new Map());
const [missionHealthById, setMissionHealthById] = useState<Map<string, MissionHealth>>(new Map());
const [activeTab, setActiveTab] = useState<"structure" | "activity">("structure");
@@ -650,6 +724,92 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
};
// Handler for validator run started - refresh feature loop state and validation runs
const handleValidatorRunStarted = (rawEvent: Event) => {
const messageEvent = rawEvent as MessageEvent<string>;
if (!messageEvent.data) return;
try {
const payload = JSON.parse(messageEvent.data);
if (payload && payload.featureId) {
// Refresh feature loop state
void loadFeatureLoopState(payload.featureId);
// Refresh validation runs
void loadValidationRuns(payload.featureId);
}
} catch {
// ignore invalid payloads
}
};
// Handler for validator run completed - refresh feature loop state, runs, and mission detail
const handleValidatorRunCompleted = (rawEvent: Event) => {
const messageEvent = rawEvent as MessageEvent<string>;
if (!messageEvent.data) return;
try {
const payload = JSON.parse(messageEvent.data);
if (payload && payload.featureId) {
// Refresh feature loop state
void loadFeatureLoopState(payload.featureId);
// Refresh validation runs
void loadValidationRuns(payload.featureId);
// Refresh mission detail to update feature status
if (selectedMission) {
void loadMissionDetail(selectedMission.id);
}
}
} catch {
// ignore invalid payloads
}
};
// Handler for milestone validation updated - refresh validation rollup
const handleMilestoneValidationUpdated = (rawEvent: Event) => {
const messageEvent = rawEvent as MessageEvent<string>;
if (!messageEvent.data) return;
try {
const payload = JSON.parse(messageEvent.data);
if (payload && payload.milestoneId) {
void loadValidationRollup(payload.milestoneId);
}
} catch {
// ignore invalid payloads
}
};
// Handler for assertion mutations - refresh assertions and validation rollup
const handleAssertionMutation = (rawEvent: Event) => {
const messageEvent = rawEvent as MessageEvent<string>;
if (!messageEvent.data) return;
try {
const payload = JSON.parse(messageEvent.data);
if (payload && payload.milestoneId) {
void loadAssertionsForMilestone(payload.milestoneId);
void loadValidationRollup(payload.milestoneId);
}
} catch {
// ignore invalid payloads
}
};
// Handler for fix-feature:created - refresh mission detail to show new fix feature with lineage
const handleFixFeatureCreated = (rawEvent: Event) => {
const messageEvent = rawEvent as MessageEvent<string>;
if (!messageEvent.data) return;
try {
const payload = JSON.parse(messageEvent.data);
if (payload && payload.sourceFeatureId) {
// Refresh feature loop state for the source feature
void loadFeatureLoopState(payload.sourceFeatureId);
// Refresh mission detail to show the new fix feature in the list
if (selectedMission) {
void loadMissionDetail(selectedMission.id);
}
}
} catch {
// ignore invalid payloads
}
};
const handleMissionEvent = (rawEvent: Event) => {
refreshHealth();
@@ -698,12 +858,31 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
eventSource.addEventListener("slice:updated", handleSliceUpdated);
eventSource.addEventListener("feature:updated", handleFeatureUpdated);
eventSource.addEventListener("mission:event", handleMissionEvent);
// Validation events
eventSource.addEventListener("validator-run:started", handleValidatorRunStarted);
eventSource.addEventListener("validator-run:completed", handleValidatorRunCompleted);
eventSource.addEventListener("milestone:validation:updated", handleMilestoneValidationUpdated);
eventSource.addEventListener("assertion:created", handleAssertionMutation);
eventSource.addEventListener("assertion:updated", handleAssertionMutation);
eventSource.addEventListener("assertion:deleted", handleAssertionMutation);
eventSource.addEventListener("assertion:linked", handleAssertionMutation);
eventSource.addEventListener("assertion:unlinked", handleAssertionMutation);
eventSource.addEventListener("fix-feature:created", handleFixFeatureCreated);
return () => {
eventSource.removeEventListener("mission:updated", handleMissionUpdated);
eventSource.removeEventListener("slice:updated", handleSliceUpdated);
eventSource.removeEventListener("feature:updated", handleFeatureUpdated);
eventSource.removeEventListener("mission:event", handleMissionEvent);
eventSource.removeEventListener("validator-run:started", handleValidatorRunStarted);
eventSource.removeEventListener("validator-run:completed", handleValidatorRunCompleted);
eventSource.removeEventListener("milestone:validation:updated", handleMilestoneValidationUpdated);
eventSource.removeEventListener("assertion:created", handleAssertionMutation);
eventSource.removeEventListener("assertion:updated", handleAssertionMutation);
eventSource.removeEventListener("assertion:deleted", handleAssertionMutation);
eventSource.removeEventListener("assertion:linked", handleAssertionMutation);
eventSource.removeEventListener("assertion:unlinked", handleAssertionMutation);
eventSource.removeEventListener("fix-feature:created", handleFixFeatureCreated);
eventSource.close();
};
}, [
@@ -871,10 +1050,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const toggleMilestoneExpanded = useCallback((milestoneId: string) => {
setExpandedMilestones((prev) => {
const next = new Set(prev);
if (next.has(milestoneId)) {
next.delete(milestoneId);
} else {
const isExpanding = !next.has(milestoneId);
if (isExpanding) {
next.add(milestoneId);
// Load assertions and validation rollup when expanding milestone
void loadAssertionsForMilestone(milestoneId);
void loadValidationRollup(milestoneId);
} else {
next.delete(milestoneId);
}
return next;
});
@@ -1120,6 +1303,253 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}, [addToast, loadMissionDetail, selectedMission, projectId]);
// ── Assertion handlers ──
const loadAssertionsForMilestone = useCallback(async (milestoneId: string) => {
try {
const assertions = await fetchAssertions(milestoneId, projectId);
setAssertionsByMilestone((prev) => {
const next = new Map(prev);
next.set(milestoneId, assertions);
return next;
});
} catch (err: any) {
// Silently fail - assertions are optional
}
}, [projectId]);
const loadValidationRollup = useCallback(async (milestoneId: string) => {
try {
const rollup = await fetchMilestoneValidation(milestoneId, projectId);
setValidationRollupByMilestone((prev) => {
const next = new Map(prev);
next.set(milestoneId, rollup);
return next;
});
} catch (err: any) {
// Silently fail
}
}, [projectId]);
const handleCreateAssertion = useCallback(async (milestoneId: string) => {
if (!assertionForm.title.trim() || !assertionForm.assertion.trim()) {
addToast("Title and assertion text are required", "error");
return;
}
try {
setSaving(true);
await createAssertion(milestoneId, {
title: assertionForm.title.trim(),
assertion: assertionForm.assertion.trim(),
status: assertionForm.status,
}, projectId);
addToast("Assertion created", "success");
await loadAssertionsForMilestone(milestoneId);
await loadValidationRollup(milestoneId);
setIsCreatingAssertion(false);
setAssertionForm({ title: "", assertion: "", status: "pending" });
} catch (err: any) {
addToast(err.message || "Failed to create assertion", "error");
} finally {
setSaving(false);
}
}, [assertionForm, addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]);
const handleEditAssertion = useCallback((assertion: MissionContractAssertion) => {
setEditingAssertionId(assertion.id);
setAssertionForm({
title: assertion.title,
assertion: assertion.assertion,
status: assertion.status,
});
}, []);
const handleCancelAssertion = useCallback(() => {
setEditingAssertionId(null);
setIsCreatingAssertion(false);
setAssertionForm({ title: "", assertion: "", status: "pending" });
}, []);
const handleSaveAssertion = useCallback(async (assertionId: string, milestoneId: string) => {
if (!assertionForm.title.trim() || !assertionForm.assertion.trim()) {
addToast("Title and assertion text are required", "error");
return;
}
try {
setSaving(true);
await updateAssertion(assertionId, {
title: assertionForm.title.trim(),
assertion: assertionForm.assertion.trim(),
status: assertionForm.status,
}, projectId);
addToast("Assertion updated", "success");
await loadAssertionsForMilestone(milestoneId);
await loadValidationRollup(milestoneId);
handleCancelAssertion();
} catch (err: any) {
addToast(err.message || "Failed to update assertion", "error");
} finally {
setSaving(false);
}
}, [assertionForm, addToast, loadAssertionsForMilestone, loadValidationRollup, handleCancelAssertion, projectId]);
const handleDeleteAssertion = useCallback(async (assertionId: string, milestoneId: string) => {
try {
await deleteAssertion(assertionId, projectId);
addToast("Assertion deleted", "success");
await loadAssertionsForMilestone(milestoneId);
await loadValidationRollup(milestoneId);
setDeleteConfirmId(null);
} catch (err: any) {
addToast(err.message || "Failed to delete assertion", "error");
}
}, [addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]);
const loadLinkedFeaturesForAssertion = useCallback(async (assertionId: string) => {
try {
const features = await fetchFeaturesForAssertion(assertionId, projectId);
setLinkedFeaturesByAssertion((prev) => {
const next = new Map(prev);
next.set(assertionId, features);
return next;
});
} catch (err: any) {
// Silently fail
}
}, [projectId]);
const handleToggleAssertionExpanded = useCallback(async (assertionId: string) => {
const isExpanding = expandedAssertionId !== assertionId;
setExpandedAssertionId((prev) => (prev === assertionId ? null : assertionId));
if (isExpanding) {
await loadLinkedFeaturesForAssertion(assertionId);
}
}, [expandedAssertionId, loadLinkedFeaturesForAssertion]);
const handleLinkFeatureToAssertion = useCallback(async (featureId: string, assertionId: string) => {
try {
setLinkingAssertions((prev) => new Set(prev).add(assertionId));
await linkFeatureToAssertion(featureId, assertionId, projectId);
addToast("Feature linked to assertion", "success");
await loadLinkedFeaturesForAssertion(assertionId);
setFeaturePickerOpenForAssertion(null);
} catch (err: any) {
addToast(err.message || "Failed to link feature", "error");
} finally {
setLinkingAssertions((prev) => {
const next = new Set(prev);
next.delete(assertionId);
return next;
});
}
}, [addToast, loadLinkedFeaturesForAssertion, projectId]);
const handleUnlinkFeatureFromAssertion = useCallback(async (featureId: string, assertionId: string) => {
const key = `${featureId}-${assertionId}`;
try {
setUnlinkingFeatures((prev) => new Set(prev).add(key));
await unlinkFeatureFromAssertion(featureId, assertionId, projectId);
addToast("Feature unlinked from assertion", "success");
await loadLinkedFeaturesForAssertion(assertionId);
} catch (err: any) {
addToast(err.message || "Failed to unlink feature", "error");
} finally {
setUnlinkingFeatures((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}
}, [addToast, loadLinkedFeaturesForAssertion, projectId]);
// ── Validation trigger ──
const handleTriggerValidation = useCallback(async (featureId: string) => {
try {
setValidatingFeatures((prev) => new Set(prev).add(featureId));
await triggerValidation(featureId, projectId);
addToast("Validation triggered", "success");
// Reload feature loop state
const snapshot = await fetchValidationLoopState(featureId, projectId);
setFeatureLoopStates((prev) => {
const next = new Map(prev);
next.set(featureId, snapshot);
return next;
});
} catch (err: any) {
addToast(err.message || "Failed to trigger validation", "error");
} finally {
setValidatingFeatures((prev) => {
const next = new Set(prev);
next.delete(featureId);
return next;
});
}
}, [addToast, projectId]);
const loadFeatureLoopState = useCallback(async (featureId: string) => {
try {
const snapshot = await fetchValidationLoopState(featureId, projectId);
setFeatureLoopStates((prev) => {
const next = new Map(prev);
next.set(featureId, snapshot);
return next;
});
} catch (err: any) {
// Silently fail
}
}, [projectId]);
// Load validation runs for a feature
const loadValidationRuns = useCallback(async (featureId: string) => {
try {
const runs = await fetchValidationRuns(featureId, { limit: 10 }, projectId);
setValidationRunsByFeature((prev) => {
const next = new Map(prev);
next.set(featureId, runs);
return next;
});
} catch (err: any) {
// Silently fail
}
}, [projectId]);
// Load run detail with failures
const loadRunDetail = useCallback(async (runId: string) => {
try {
const detail = await fetchValidationRun(runId, projectId);
setRunDetailsByRunId((prev) => {
const next = new Map(prev);
next.set(runId, detail);
return next;
});
} catch (err: any) {
// Silently fail
}
}, [projectId]);
// Toggle feature expansion to show run history
const toggleFeatureExpanded = useCallback(async (featureId: string) => {
if (expandedFeatureId === featureId) {
setExpandedFeatureId(null);
} else {
setExpandedFeatureId(featureId);
// Load loop state and validation runs when expanding
await loadFeatureLoopState(featureId);
await loadValidationRuns(featureId);
}
}, [expandedFeatureId, loadFeatureLoopState, loadValidationRuns]);
// Toggle run expansion to show failures
const toggleRunExpanded = useCallback(async (runId: string) => {
if (expandedRunId === runId) {
setExpandedRunId(null);
} else {
setExpandedRunId(runId);
await loadRunDetail(runId);
}
}, [expandedRunId, loadRunDetail]);
// Resume a paused mission — set status back to "active"
const handleResumeMission = useCallback(async (missionId: string) => {
try {
@@ -1526,6 +1956,49 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
</span>
<span className="mission-milestone__count">{milestone.slices.length} slices</span>
<PlanStateIndicator state={getMilestonePlanState(milestone.interviewState)} />
{/* Validation state badge and coverage bar in milestone header */}
{validationRollupByMilestone.get(milestone.id) && (
<>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: validationRollupByMilestone.get(milestone.id)?.state === "passed"
? "var(--loop-passed-bg)"
: validationRollupByMilestone.get(milestone.id)?.state === "failed"
? "var(--loop-blocked-bg)"
: validationRollupByMilestone.get(milestone.id)?.state === "blocked"
? "var(--loop-blocked-bg)"
: "var(--assertion-pending-bg)",
color: validationRollupByMilestone.get(milestone.id)?.state === "passed"
? "var(--loop-passed-text)"
: validationRollupByMilestone.get(milestone.id)?.state === "failed"
? "var(--loop-blocked-text)"
: validationRollupByMilestone.get(milestone.id)?.state === "blocked"
? "var(--loop-blocked-text)"
: "var(--assertion-pending-text)",
}}
title="Validation state"
>
{validationRollupByMilestone.get(milestone.id)?.state || "not_started"}
</span>
{validationRollupByMilestone.get(milestone.id)!.totalAssertions > 0 && (
<div
className="mission-milestone__coverage-bar"
title={`${validationRollupByMilestone.get(milestone.id)!.passedCount} of ${validationRollupByMilestone.get(milestone.id)!.totalAssertions} assertions passing`}
>
<div
className="mission-milestone__coverage-bar-fill"
style={{
width: `${(validationRollupByMilestone.get(milestone.id)!.passedCount / validationRollupByMilestone.get(milestone.id)!.totalAssertions) * 100}%`,
backgroundColor: validationRollupByMilestone.get(milestone.id)!.passedCount === validationRollupByMilestone.get(milestone.id)!.totalAssertions
? "var(--color-success)"
: "var(--color-warning)",
}}
/>
</div>
)}
</>
)}
{milestone.status !== "complete" && (
<button
className="mission-icon-btn"
@@ -1740,6 +2213,13 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{slice.features?.map((feature) => (
<div key={feature.id} className="mission-feature">
<div className="mission-feature__header">
<button
className="mission-feature__expand"
onClick={() => toggleFeatureExpanded(feature.id)}
title={expandedFeatureId === feature.id ? "Collapse details" : "Expand to show run history"}
>
{expandedFeatureId === feature.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<Box size={14} className="mission-feature__icon" />
<span className="mission-feature__title">{feature.title}</span>
<span
@@ -1751,6 +2231,52 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
>
{feature.status}
</span>
{/* Loop state indicator */}
{(feature.loopState && feature.loopState !== "idle") && (
<span
className={`mission-loop-state mission-loop-state--${feature.loopState}`}
title={`Loop state: ${feature.loopState}`}
>
{feature.loopState === "implementing" && "⏳"}
{feature.loopState === "validating" && "🔄"}
{feature.loopState === "needs_fix" && "🔧"}
{feature.loopState === "passed" && "✅"}
{feature.loopState === "blocked" && "🚫"}
</span>
)}
{/* Lineage indicator for fix features */}
{feature.generatedFromFeatureId && (
<span
className="mission-feature__lineage"
title={`Generated from fix for assertion failure`}
>
🔗 Fix
</span>
)}
{/* Retry budget display */}
{feature.loopState && feature.loopState !== "idle" && featureLoopStates.get(feature.id) && (
<span
className="mission-feature__retry-budget"
title="Implementation attempts remaining"
>
Attempt {featureLoopStates.get(feature.id)!.implementationAttemptCount} of {featureLoopStates.get(feature.id)!.implementationAttemptCount + featureLoopStates.get(feature.id)!.retryBudgetRemaining}
</span>
)}
{/* Validation trigger button for implementing features */}
{feature.loopState === "implementing" && (
<button
className="mission-icon-btn mission-icon-btn--validate"
onClick={() => handleTriggerValidation(feature.id)}
title="Validate feature"
disabled={validatingFeatures.has(feature.id)}
>
{validatingFeatures.has(feature.id) ? (
<Loader2 size={14} className="spinner" />
) : (
<Sparkles size={14} />
)}
</button>
)}
{feature.taskId && (
<span
className="mission-feature__task-link"
@@ -1890,6 +2416,89 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
</div>
</div>
)}
{/* Validation Run History - shown when feature is expanded */}
{expandedFeatureId === feature.id && (
<div className="mission-feature__run-history">
<div className="mission-feature__run-history-header">
<span className="mission-feature__run-history-title">Validation Runs</span>
</div>
{validationRunsByFeature.get(feature.id)?.map((run) => (
<div key={run.id} className="mission-run">
<div
className="mission-run__header"
onClick={() => toggleRunExpanded(run.id)}
>
<span
className={`mission-status-badge mission-status-badge--sm mission-run__status mission-run__status--${run.status}`}
title={run.status}
>
{run.status}
</span>
<span className="mission-run__time">
{new Date(run.startedAt).toLocaleString()}
</span>
{run.completedAt && (
<span className="mission-run__duration">
{Math.round((new Date(run.completedAt).getTime() - new Date(run.startedAt).getTime()) / 1000)}s
</span>
)}
{run.triggerType && (
<span className="mission-run__trigger">
{run.triggerType}
</span>
)}
<button
className="mission-icon-btn"
title={expandedRunId === run.id ? "Hide details" : "Show details"}
>
{expandedRunId === run.id ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
</div>
{expandedRunId === run.id && runDetailsByRunId.get(run.id) && (
<div className="mission-run__details">
{run.summary && (
<p className="mission-run__summary">{run.summary}</p>
)}
{run.blockedReason && (
<p className="mission-run__blocked-reason">
<strong>Blocked:</strong> {run.blockedReason}
</p>
)}
{runDetailsByRunId.get(run.id)?.failures && runDetailsByRunId.get(run.id)!.failures!.length > 0 && (
<div className="mission-run__failures">
<span className="mission-run__failures-title">Failed Assertions:</span>
{runDetailsByRunId.get(run.id)!.failures!.map((failure) => (
<div key={failure.id} className="mission-run__failure">
<span className="mission-run__failure-message">{failure.message}</span>
{failure.expected && (
<span className="mission-run__failure-expected">
Expected: {failure.expected}
</span>
)}
{failure.actual && (
<span className="mission-run__failure-actual">
Actual: {failure.actual}
</span>
)}
</div>
))}
</div>
)}
{(!runDetailsByRunId.get(run.id)?.failures || runDetailsByRunId.get(run.id)!.failures!.length === 0) && (
<p className="mission-run__no-failures">No assertion failures</p>
)}
</div>
)}
</div>
))}
{(!validationRunsByFeature.get(feature.id) || validationRunsByFeature.get(feature.id)!.length === 0) && (
<div className="mission-run-history__empty">
No validation runs yet.
</div>
)}
</div>
)}
</div>
))}
@@ -1939,6 +2548,265 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<span>No slices yet</span>
</div>
)}
{/* Assertions Panel */}
<div className="mission-assertions">
<div className="mission-assertions__header">
<span className="mission-assertions__title">Assertions</span>
{validationRollupByMilestone.get(milestone.id) && (
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: validationRollupByMilestone.get(milestone.id)?.state === "passed"
? "var(--loop-passed-bg)"
: validationRollupByMilestone.get(milestone.id)?.state === "failed"
? "var(--loop-blocked-bg)"
: "var(--assertion-pending-bg)",
color: validationRollupByMilestone.get(milestone.id)?.state === "passed"
? "var(--loop-passed-text)"
: validationRollupByMilestone.get(milestone.id)?.state === "failed"
? "var(--loop-blocked-text)"
: "var(--assertion-pending-text)",
}}
>
{validationRollupByMilestone.get(milestone.id)?.state || "not_started"}
</span>
)}
{/* Assertion coverage bar */}
{validationRollupByMilestone.get(milestone.id) && validationRollupByMilestone.get(milestone.id)!.totalAssertions > 0 && (
<div className="mission-assertions__coverage-bar" title={`${validationRollupByMilestone.get(milestone.id)!.passedCount} of ${validationRollupByMilestone.get(milestone.id)!.totalAssertions} assertions passing`}>
<div
className="mission-assertions__coverage-bar-fill"
style={{
width: `${(validationRollupByMilestone.get(milestone.id)!.passedCount / validationRollupByMilestone.get(milestone.id)!.totalAssertions) * 100}%`,
backgroundColor: validationRollupByMilestone.get(milestone.id)!.passedCount === validationRollupByMilestone.get(milestone.id)!.totalAssertions
? "var(--color-success)"
: "var(--color-warning)",
}}
/>
</div>
)}
<button
className="mission-icon-btn"
onClick={() => {
setIsCreatingAssertion(true);
setEditingAssertionId(null);
setAssertionForm({ title: "", assertion: "", status: "pending" });
}}
title="Add assertion"
>
<Plus size={14} />
</button>
</div>
{/* Create assertion form */}
{isCreatingAssertion && (
<div className="mission-form-card">
<input
type="text"
placeholder="Assertion title"
value={assertionForm.title}
onChange={(e) => setAssertionForm({ ...assertionForm, title: e.target.value })}
autoFocus
/>
<textarea
placeholder="Assertion text (what should be true when complete)"
value={assertionForm.assertion}
onChange={(e) => setAssertionForm({ ...assertionForm, assertion: e.target.value })}
rows={2}
/>
<select
value={assertionForm.status}
onChange={(e) => setAssertionForm({ ...assertionForm, status: e.target.value as MissionAssertionStatus })}
>
<option value="pending">Pending</option>
<option value="passed">Passed</option>
<option value="failed">Failed</option>
<option value="blocked">Blocked</option>
</select>
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={() => handleCreateAssertion(milestone.id)} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
Create
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelAssertion}>
Cancel
</button>
</div>
</div>
)}
{/* Assertions list */}
<div className="mission-assertions__list">
{assertionsByMilestone.get(milestone.id)?.map((assertion) => (
<div key={assertion.id} className="mission-assertion">
<div className="mission-assertion__header">
{editingAssertionId === assertion.id ? (
<div className="mission-form-card">
<input
type="text"
placeholder="Assertion title"
value={assertionForm.title}
onChange={(e) => setAssertionForm({ ...assertionForm, title: e.target.value })}
autoFocus
/>
<textarea
placeholder="Assertion text"
value={assertionForm.assertion}
onChange={(e) => setAssertionForm({ ...assertionForm, assertion: e.target.value })}
rows={2}
/>
<select
value={assertionForm.status}
onChange={(e) => setAssertionForm({ ...assertionForm, status: e.target.value as MissionAssertionStatus })}
>
<option value="pending">Pending</option>
<option value="passed">Passed</option>
<option value="failed">Failed</option>
<option value="blocked">Blocked</option>
</select>
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={() => handleSaveAssertion(assertion.id, milestone.id)} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
Save
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelAssertion}>
Cancel
</button>
</div>
</div>
) : (
<>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: assertionStatusColors[assertion.status].bg,
color: assertionStatusColors[assertion.status].text,
}}
>
{assertion.status}
</span>
<span className="mission-assertion__title">{assertion.title}</span>
{(() => {
const linked = linkedFeaturesByAssertion.get(assertion.id);
const count = linked?.length ?? 0;
return count > 0 ? (
<span className="mission-assertion__linked-count" title={`${count} linked feature${count !== 1 ? "s" : ""}`}>
({count} linked)
</span>
) : null;
})()}
<button
className="mission-icon-btn"
onClick={() => handleToggleAssertionExpanded(assertion.id)}
title="Toggle details"
>
{expandedAssertionId === assertion.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<button
className="mission-icon-btn"
onClick={() => handleEditAssertion(assertion)}
title="Edit assertion"
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "assertion", id: assertion.id })}
title="Delete assertion"
>
<Trash2 size={14} />
</button>
</>
)}
</div>
{expandedAssertionId === assertion.id && (
<div className="mission-assertion__body">
<p className="mission-assertion__text">{assertion.assertion}</p>
{/* Linked features section */}
<div className="mission-assertion__linked-features">
<div className="mission-assertion__linked-features-header">
<span className="mission-assertion__linked-features-label">Linked Features</span>
<button
className="mission-btn mission-btn--ghost mission-btn--sm"
onClick={async () => {
// First expand the assertion if it's not already expanded
if (expandedAssertionId !== assertion.id) {
await handleToggleAssertionExpanded(assertion.id);
}
// Then toggle the picker
setFeaturePickerOpenForAssertion(featurePickerOpenForAssertion === assertion.id ? null : assertion.id);
}}
title="Link a feature"
>
<Link size={12} />
Link Feature
</button>
</div>
{/* Feature picker dropdown */}
{featurePickerOpenForAssertion === assertion.id && (
<div className="mission-assertion__feature-picker">
<div className="mission-assertion__feature-picker-dropdown">
{(() => {
const linkedFeatureIds = new Set((linkedFeaturesByAssertion.get(assertion.id) ?? []).map((f) => f.id));
const allFeatures: MissionFeature[] = [];
selectedMission?.milestones.forEach((m) =>
m.slices.forEach((s) => allFeatures.push(...s.features.filter((f) => !linkedFeatureIds.has(f.id))))
);
if (allFeatures.length === 0) {
return <span className="mission-assertion__feature-picker-empty">All features already linked</span>;
}
return allFeatures.map((feature) => (
<button
key={feature.id}
className="mission-assertion__feature-picker-item"
onClick={() => handleLinkFeatureToAssertion(feature.id, assertion.id)}
disabled={linkingAssertions.has(assertion.id)}
>
<span className="mission-assertion__feature-picker-title">{feature.title}</span>
{linkingAssertions.has(assertion.id) && <Loader2 size={12} className="spinner" />}
</button>
));
})()}
</div>
</div>
)}
{/* Linked features list */}
{(() => {
const linked = linkedFeaturesByAssertion.get(assertion.id) ?? [];
if (linked.length === 0) {
return <span className="mission-assertion__linked-empty">No features linked yet</span>;
}
return linked.map((feature) => {
const key = `${feature.id}-${assertion.id}`;
const isUnlinking = unlinkingFeatures.has(key);
return (
<div key={feature.id} className="mission-assertion__linked-feature">
<span className="mission-assertion__linked-feature-title">{feature.title}</span>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => handleUnlinkFeatureFromAssertion(feature.id, assertion.id)}
disabled={isUnlinking}
title="Unlink feature"
>
{isUnlinking ? <Loader2 size={12} className="spinner" /> : <Unlink size={12} />}
</button>
</div>
);
});
})()}
</div>
</div>
)}
</div>
))}
{(!assertionsByMilestone.get(milestone.id) || assertionsByMilestone.get(milestone.id)?.length === 0) && !isCreatingAssertion && (
<div className="mission-manager__empty mission-assertions__empty">
<span>No assertions defined. Add one to define completion criteria.</span>
</div>
)}
</div>
</div>
</div>
</div>
)}

View File

@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { MessageSquare, Send, X } from "lucide-react";
import type { Message } from "@fusion/core";
import type { Agent } from "../api";
import { fetchConversation, sendMessage } from "../api";
import { useQuickChat, type ChatMessageInfo } from "../hooks/useQuickChat";
import { useAgents } from "../hooks/useAgents";
interface QuickChatFABProps {
@@ -37,19 +36,30 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
}
: setInternalOpen;
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
const [messages, setMessages] = useState<Message[]>([]);
const [isConversationLoading, setIsConversationLoading] = useState(false);
const [messageInput, setMessageInput] = useState("");
const [isSending, setIsSending] = useState(false);
// Chat session hook
const {
messages,
isStreaming,
streamingText,
streamingThinking,
sessionsLoading,
messagesLoading,
sendMessage,
switchSession,
} = useQuickChat(projectId, addToast);
const panelRef = useRef<HTMLDivElement | null>(null);
const fabRef = useRef<HTMLButtonElement | null>(null);
const messagesRef = useRef<HTMLDivElement | null>(null);
// Track the previous agent ID to detect changes
const prevAgentIdRef = useRef<string>("");
useEffect(() => {
if (agents.length === 0) {
setSelectedAgentId("");
setMessages([]);
return;
}
@@ -59,34 +69,31 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
}
}, [agents, selectedAgentId]);
// Initialize session when an agent is selected and panel opens
useEffect(() => {
if (!isOpen || !selectedAgentId) return;
if (selectedAgentId !== prevAgentIdRef.current) {
prevAgentIdRef.current = selectedAgentId;
void switchSession(selectedAgentId);
}
}, [isOpen, selectedAgentId, switchSession]);
// Handle agent selector changes
const handleAgentChange = useCallback(
(agentId: string) => {
setSelectedAgentId(agentId);
prevAgentIdRef.current = agentId;
void switchSession(agentId);
},
[switchSession],
);
const selectedAgent = useMemo(
() => agents.find((agent) => agent.id === selectedAgentId) ?? null,
[agents, selectedAgentId],
);
const loadConversation = useCallback(async (agentId: string) => {
if (!agentId) {
setMessages([]);
return;
}
setIsConversationLoading(true);
try {
const conversation = await fetchConversation(agentId, "agent", projectId);
setMessages(conversation);
} catch {
addToast("Failed to load conversation", "error");
setMessages([]);
} finally {
setIsConversationLoading(false);
}
}, [addToast, projectId]);
useEffect(() => {
if (!isOpen || !selectedAgentId) return;
void loadConversation(selectedAgentId);
}, [isOpen, selectedAgentId, loadConversation]);
// Click outside and escape handling
useEffect(() => {
if (!isOpen) return;
@@ -112,36 +119,21 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
};
}, [isOpen]);
// Auto-scroll messages
useEffect(() => {
if (!isOpen) return;
const messagesEl = messagesRef.current;
if (!messagesEl) return;
messagesEl.scrollTop = messagesEl.scrollHeight;
}, [messages, isOpen]);
}, [messages, streamingText, streamingThinking, isOpen]);
const handleSendMessage = useCallback(async () => {
const trimmed = messageInput.trim();
if (!selectedAgentId || !trimmed || isSending) return;
if (!selectedAgentId || !trimmed || isStreaming) return;
setIsSending(true);
try {
await sendMessage(
{
toId: selectedAgentId,
toType: "agent",
content: trimmed,
type: "user-to-agent",
},
projectId,
);
setMessageInput("");
await loadConversation(selectedAgentId);
} catch {
addToast("Failed to send message", "error");
} finally {
setIsSending(false);
}
}, [addToast, isSending, loadConversation, messageInput, projectId, selectedAgentId]);
setMessageInput("");
await sendMessage(trimmed);
}, [sendMessage, isStreaming, messageInput, selectedAgentId]);
const handleInputKeyDown = useCallback((event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key !== "Enter" || event.shiftKey) return;
@@ -188,7 +180,7 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
<select
id="quick-chat-agent-select"
value={selectedAgentId}
onChange={(event) => setSelectedAgentId(event.target.value)}
onChange={(event) => handleAgentChange(event.target.value)}
data-testid="quick-chat-agent-select"
>
{agents.map((agent) => (
@@ -200,23 +192,41 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
</div>
<div className="quick-chat-panel-messages" ref={messagesRef} data-testid="quick-chat-messages">
{isConversationLoading ? (
{sessionsLoading || messagesLoading ? (
<div className="quick-chat-panel-empty">Loading conversation</div>
) : messages.length === 0 ? (
) : messages.length === 0 && !streamingText && !streamingThinking ? (
<div className="quick-chat-panel-empty">No messages yet. Start the conversation!</div>
) : (
messages.map((message) => {
const isSent = message.fromType === "user";
return (
<>
{messages.map((message: ChatMessageInfo) => {
const isSent = message.role === "user";
return (
<div
key={message.id}
className={`quick-chat-panel-message ${isSent ? "quick-chat-panel-message--sent" : "quick-chat-panel-message--received"}`}
data-testid={`quick-chat-message-${message.id}`}
>
<p>{message.content}</p>
</div>
);
})}
{/* Streaming message bubble */}
{(streamingText || streamingThinking) && (
<div
key={message.id}
className={`quick-chat-panel-message ${isSent ? "quick-chat-panel-message--sent" : "quick-chat-panel-message--received"}`}
data-testid={`quick-chat-message-${message.id}`}
className="quick-chat-panel-message quick-chat-panel-message--received quick-chat-panel-message--streaming"
data-testid="quick-chat-streaming-message"
>
<p>{message.content}</p>
{streamingThinking && (
<p className="quick-chat-panel-thinking" data-testid="quick-chat-streaming-thinking">
{streamingThinking}
</p>
)}
{streamingText && (
<p data-testid="quick-chat-streaming-text">{streamingText}</p>
)}
</div>
);
})
)}
</>
)}
</div>
@@ -227,13 +237,13 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
onChange={(event) => setMessageInput(event.target.value)}
onKeyDown={handleInputKeyDown}
placeholder={selectedAgent ? `Message ${selectedAgent.name || selectedAgent.id}` : "Type a message"}
disabled={!selectedAgentId || isSending}
disabled={!selectedAgentId || isStreaming}
data-testid="quick-chat-input"
/>
<button
type="button"
onClick={() => void handleSendMessage()}
disabled={!selectedAgentId || messageInput.trim().length === 0 || isSending}
disabled={!selectedAgentId || messageInput.trim().length === 0 || isStreaming}
data-testid="quick-chat-send"
>
<Send size={16} />

View File

@@ -93,6 +93,8 @@ interface TaskCardProps {
onOpenMission?: (missionId: string) => void;
/** Called when user moves a task to a different column from the card. */
onMoveTask?: (id: string, column: Column) => Promise<Task>;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
}
function areTaskBadgeInfosEqual(
@@ -229,6 +231,7 @@ function TaskCardComponent({
taskStuckTimeoutMs,
onOpenMission,
onMoveTask,
lastFetchTimeMs,
}: TaskCardProps) {
const [dragging, setDragging] = useState(false);
const [fileDragOver, setFileDragOver] = useState(false);
@@ -467,7 +470,7 @@ function TaskCardComponent({
const isFailed = task.status === "failed";
const isPaused = task.paused === true;
const isStuck = isTaskStuck(task, taskStuckTimeoutMs);
const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval";
const isArchived = task.column === "archived";
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
@@ -924,6 +927,9 @@ function TaskCardComponent({
})()}
{task.worktree && (task.column === "in-progress" || task.column === "in-review") && (() => {
const activeCount = diffStats?.filesChanged;
if (activeCount == null || activeCount === 0) {
return null;
}
return (
<button
type="button"
@@ -932,11 +938,7 @@ function TaskCardComponent({
disabled={!onOpenDetailWithTab}
>
<Folder size={12} />
<span>
{activeCount != null && activeCount > 0
? `${activeCount} ${activeCount === 1 ? "file" : "files"} changed`
: "View files"}
</span>
<span>{activeCount} {activeCount === 1 ? "file" : "files"} changed</span>
</button>
);
})()}

View File

@@ -1944,4 +1944,34 @@ describe("UsageIndicator", () => {
// Tomorrow should always be weekday format, never month/day
expect(resetAtEl?.textContent).toMatch(/^[A-Z][a-z]{2} \d{1,2}:\d{2} [AP]M$/);
});
// Email display tests
it("does not render provider email even when email is present in data", () => {
mockUseUsageData.mockReturnValue({
providers: [
{
name: "Claude",
icon: "🟠",
status: "ok",
email: "user@example.com",
plan: "Pro",
windows: [
{ label: "Session", percentUsed: 10, percentLeft: 90, resetText: "resets in 4h" },
],
},
],
loading: false,
error: null,
lastUpdated: new Date(),
refresh: mockRefresh,
});
render(<UsageIndicator isOpen={true} onClose={mockOnClose} projectId={TEST_PROJECT_ID} />);
// Plan should be visible
expect(screen.getByText("Pro")).toBeInTheDocument();
// Email should NOT be rendered
expect(screen.queryByText("user@example.com")).not.toBeInTheDocument();
expect(document.querySelector(".usage-provider-email")).not.toBeInTheDocument();
});
});

View File

@@ -287,10 +287,9 @@ function ProviderCard({ provider, viewMode }: ProviderCardProps) {
</div>
)}
{(provider.plan || provider.email) && (
{provider.plan && (
<div className="usage-provider-meta">
{provider.plan && <span className="usage-provider-plan">{provider.plan}</span>}
{provider.email && <span className="usage-provider-email">{provider.email}</span>}
<span className="usage-provider-plan">{provider.plan}</span>
</div>
)}

View File

@@ -21,6 +21,8 @@ interface WorktreeGroupProps {
taskStuckTimeoutMs?: number;
/** Called when user clicks a mission badge on a task card */
onOpenMission?: (missionId: string) => void;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
}
function WorktreeGroupComponent({
@@ -35,6 +37,7 @@ function WorktreeGroupComponent({
onOpenDetailWithTab,
taskStuckTimeoutMs,
onOpenMission,
lastFetchTimeMs,
}: WorktreeGroupProps) {
return (
<div className="worktree-group">
@@ -45,7 +48,7 @@ function WorktreeGroupComponent({
<span className="worktree-label">{label}</span>
</div>
{activeTasks.map((task) => (
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} />
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} />
))}
{queuedTasks.map((task) => (
<TaskCard
@@ -60,6 +63,7 @@ function WorktreeGroupComponent({
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
/>
))}
</div>

View File

@@ -41,7 +41,6 @@ vi.mock("../../api", async (importOriginal) => {
fetchScripts: vi.fn(() => Promise.resolve({ build: "npm run build", test: "pnpm test" })),
runScript: vi.fn(() => Promise.resolve({ sessionId: "sess-script-1", command: "echo hello" })),
killPtyTerminalSession: vi.fn(() => Promise.resolve({ killed: true })),
fetchScripts: vi.fn(() => Promise.resolve({ build: "npm run build", test: "pnpm test" })),
};
});
@@ -64,10 +63,6 @@ vi.mock("../../hooks/useTasks", () => ({
useTasks: (options?: { projectId?: string; searchQuery?: string }) => mockUseTasks(options),
}));
vi.mock("../../hooks/useTasks", () => ({
useTasks: () => mockUseTasks(),
}));
// Mock useRemoteNodeData
vi.mock("../../hooks/useRemoteNodeData", () => ({
useRemoteNodeData: vi.fn(() => ({

View File

@@ -328,13 +328,13 @@ describe("ExecutorStatusBar", () => {
const tasks: any[] = [{ id: "FN-001" }];
render(<ExecutorStatusBar tasks={tasks} projectId="proj_abc123" />);
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, "proj_abc123", undefined);
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, "proj_abc123", undefined, undefined);
});
it("passes tasks and undefined to useExecutorStats when projectId not provided", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
expect(mockUseExecutorStats).toHaveBeenCalledWith(emptyTasks, undefined, undefined);
expect(mockUseExecutorStats).toHaveBeenCalledWith(emptyTasks, undefined, undefined, undefined);
});
});
@@ -377,7 +377,7 @@ describe("ExecutorStatusBar", () => {
render(<ExecutorStatusBar tasks={tasks} />);
// useExecutorStats receives the tasks array as first argument
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, undefined, undefined);
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, undefined, undefined, undefined);
});
it("renders stuck segment with correct count when stuck tasks detected", () => {

View File

@@ -1554,6 +1554,27 @@ describe("ListView Quick Entry", () => {
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockOnQuickCreate).not.toHaveBeenCalled();
});
it("QuickEntryBox textarea spans full container width in list view (FN-1579)", () => {
mockDesktopViewport();
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
renderListView({ onQuickCreate: mockOnQuickCreate });
const quickEntryBox = screen.getByTestId("quick-entry-box");
const input = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
// Get the bounding rectangles for the textarea and its container
const inputRect = input.getBoundingClientRect();
const containerRect = quickEntryBox.getBoundingClientRect();
// The textarea should span the full width of its container (within 2px tolerance for rounding)
// This ensures the input visually reaches the right edge of the container
expect(inputRect.width).toBeGreaterThanOrEqual(containerRect.width - 2);
// The textarea should be at least 80% of the container width
// (accounting for the toggle button on the right)
expect(inputRect.width).toBeGreaterThanOrEqual(containerRect.width * 0.8);
});
});
describe("ListView Collapsible Sections", () => {

View File

@@ -1,22 +1,25 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { Message } from "@fusion/core";
import type { Agent } from "../../api";
import type { Agent, ChatSession } from "../../api";
import * as apiModule from "../../api";
import { useAgents } from "../../hooks/useAgents";
import { QuickChatFAB } from "../QuickChatFAB";
vi.mock("../../api", () => ({
fetchConversation: vi.fn(),
sendMessage: vi.fn(),
fetchChatSessions: vi.fn(),
createChatSession: vi.fn(),
fetchChatMessages: vi.fn(),
streamChatResponse: vi.fn(),
}));
vi.mock("../../hooks/useAgents", () => ({
useAgents: vi.fn(),
}));
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
const mockSendMessage = vi.mocked(apiModule.sendMessage);
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
const mockUseAgents = vi.mocked(useAgents);
const mockAgents: Agent[] = [
@@ -40,32 +43,13 @@ const mockAgents: Agent[] = [
},
];
const mockConversation: Message[] = [
{
id: "msg-001",
fromId: "agent-001",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "Hello from the agent",
type: "agent-to-user",
read: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "msg-002",
fromId: "dashboard",
fromType: "user",
toId: "agent-001",
toType: "agent",
content: "Hello back",
type: "user-to-agent",
read: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
const mockSession: ChatSession = {
id: "session-001",
agentId: "agent-001",
status: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
function mockAgentsHook(agents: Agent[], isLoading = false) {
mockUseAgents.mockReturnValue({
@@ -78,25 +62,57 @@ function mockAgentsHook(agents: Agent[], isLoading = false) {
});
}
function createMockStreamResponse() {
const handlers: {
onThinking?: (data: string) => void;
onText?: (data: string) => void;
onDone?: (data: { messageId: string }) => void;
onError?: (data: string) => void;
onConnectionStateChange?: (state: string) => void;
} = {};
const mockStream = {
close: vi.fn(),
isConnected: vi.fn(() => true),
// Allow setting handlers
setHandlers: (h: typeof handlers) => {
Object.assign(handlers, h);
},
};
// Mock streamChatResponse to capture handlers and return mock stream
mockStreamChatResponse.mockImplementation((sessionId, content, textHandlers) => {
// Store handlers for test to invoke
mockStream.setHandlers(textHandlers as typeof handlers);
// Simulate async response
setTimeout(() => {
// Simulate streaming text
textHandlers.onConnectionStateChange?.("connected");
textHandlers.onText?.("Thinking...");
textHandlers.onText?.("Here's my response.");
textHandlers.onDone?.({ messageId: `msg-${Date.now()}` });
}, 10);
return {
close: mockStream.close,
isConnected: mockStream.isConnected,
};
});
return mockStream;
}
describe("QuickChatFAB", () => {
const addToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockAgentsHook(mockAgents);
mockFetchConversation.mockResolvedValue(mockConversation);
mockSendMessage.mockResolvedValue({
id: "msg-003",
fromId: "dashboard",
fromType: "user",
toId: "agent-001",
toType: "agent",
content: "New message",
type: "user-to-agent",
read: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
mockFetchChatSessions.mockResolvedValue({ sessions: [] });
mockCreateChatSession.mockResolvedValue({ session: mockSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
createMockStreamResponse();
});
it("renders nothing when no agents exist", () => {
@@ -158,53 +174,114 @@ describe("QuickChatFAB", () => {
expect(screen.getByRole("option", { name: "Agent Two (reviewer)" })).toBeDefined();
});
it("sending a message calls sendMessage API with expected params", async () => {
it("sending a message calls streamChatResponse API with expected params", async () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Ship it" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
// Wait for streamChatResponse to be called
await waitFor(() => {
expect(mockSendMessage).toHaveBeenCalledWith(
{
toId: "agent-001",
toType: "agent",
content: "Ship it",
type: "user-to-agent",
},
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"session-001",
"Ship it",
expect.objectContaining({
onThinking: expect.any(Function),
onText: expect.any(Function),
onDone: expect.any(Function),
onError: expect.any(Function),
}),
"proj-123",
);
});
// Input should be cleared
await waitFor(() => {
expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe("");
});
});
it("switching agents loads the selected conversation", async () => {
mockFetchConversation.mockResolvedValue([]);
it("streaming state shows streaming message and disables input", async () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchConversation).toHaveBeenCalledWith("agent-001", "agent", "proj-123");
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
// Input should be cleared and disabled during streaming
await waitFor(() => {
expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe("");
});
expect(screen.getByTestId("quick-chat-input")).toBeDisabled();
});
it("after streaming completes, assistant message is shown", async () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
// Wait for streaming to complete and message to appear
await waitFor(() => {
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
// After streaming completes, input should be re-enabled
await waitFor(() => {
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
});
});
it("switching agents creates a new session for the selected agent", async () => {
// First session exists
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [mockSession] });
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for initial session to be created
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
// Switch to agent-002
fireEvent.change(screen.getByTestId("quick-chat-agent-select"), {
target: { value: "agent-002" },
});
// Should create a new session for agent-002
await waitFor(() => {
expect(mockFetchConversation).toHaveBeenCalledWith("agent-002", "agent", "proj-123");
expect(mockCreateChatSession).toHaveBeenCalledWith({ agentId: "agent-002" }, "proj-123");
});
});
it("shows placeholder text when conversation is empty", async () => {
mockFetchConversation.mockResolvedValue([]);
mockFetchChatMessages.mockResolvedValue({ messages: [] });
render(<QuickChatFAB addToast={addToast} />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
@@ -277,4 +354,35 @@ describe("QuickChatFAB", () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
it("error handling shows toast on stream error", async () => {
// Mock streamChatResponse to trigger error
mockStreamChatResponse.mockImplementationOnce((sessionId, content, textHandlers) => {
setTimeout(() => {
textHandlers.onError?.("Stream connection failed");
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn(() => false),
};
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
// Wait for error toast
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to send message", "error");
});
});
});

View File

@@ -3268,7 +3268,7 @@ describe("TaskCard singular/plural file count", () => {
expect(screen.queryByText("View files")).not.toBeInTheDocument();
});
it("falls back to View files for in-progress worktrees without a positive diff count", () => {
it("hides file changes link for in-progress worktrees when filesChanged is 0", () => {
const task = makeTask({
column: "in-progress",
worktree: "/repo/.worktrees/fn-099",
@@ -3284,7 +3284,27 @@ describe("TaskCard singular/plural file count", () => {
/>
);
expect(screen.getByText("View files")).toBeInTheDocument();
expect(screen.queryByText("View files")).not.toBeInTheDocument();
expect(screen.queryByText(/files? changed/)).not.toBeInTheDocument();
});
it("hides file changes link for in-progress worktrees when diffStats is null", () => {
const task = makeTask({
column: "in-progress",
worktree: "/repo/.worktrees/fn-099",
status: "executing",
});
mockUseTaskDiffStats.mockReturnValue({ stats: null, loading: false });
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
expect(screen.queryByText("View files")).not.toBeInTheDocument();
expect(screen.queryByText(/files? changed/)).not.toBeInTheDocument();
});

View File

@@ -12,6 +12,18 @@ export type SliceStatus = "pending" | "active" | "complete";
export type SlicePlanState = "not_started" | "planned" | "needs_update";
export type FeatureStatus = "defined" | "triaged" | "in-progress" | "done";
/** Loop state values for a feature's execution loop lifecycle */
export type FeatureLoopState = "idle" | "implementing" | "validating" | "needs_fix" | "passed" | "blocked";
/** Status values for a contract assertion */
export type MissionAssertionStatus = "pending" | "passed" | "failed" | "blocked";
/** Status values for a validator run */
export type ValidatorRunStatus = "running" | "passed" | "failed" | "blocked" | "error";
/** Validation states for a milestone's contract coverage */
export type MilestoneValidationState = "not_started" | "needs_coverage" | "ready" | "passed" | "failed" | "blocked";
/** Autopilot state values for mission autonomous progression */
export type AutopilotState = "inactive" | "watching" | "activating" | "completing";
@@ -48,6 +60,96 @@ export interface MissionFeature {
status: FeatureStatus;
createdAt: string;
updatedAt: string;
/** Current loop state for the execution loop (idle, implementing, validating, needs_fix, passed, blocked) */
loopState?: FeatureLoopState;
/** Number of implementation attempts made for this feature */
implementationAttemptCount?: number;
/** Number of validation attempts made for this feature */
validatorAttemptCount?: number;
/** ID of the last validator run for this feature */
lastValidatorRunId?: string;
/** Status of the last validator run */
lastValidatorStatus?: ValidatorRunStatus;
/** Feature ID that generated this feature (if it was a fix feature) */
generatedFromFeatureId?: string;
/** Validator run ID that generated this feature (if applicable) */
generatedFromRunId?: string;
}
/** A contract assertion represents an explicit behavioral test or requirement associated with a milestone */
export interface MissionContractAssertion {
id: string;
milestoneId: string;
title: string;
assertion: string;
status: MissionAssertionStatus;
orderIndex: number;
createdAt: string;
updatedAt: string;
}
/** Input for creating a contract assertion */
export interface ContractAssertionCreateInput {
title: string;
assertion: string;
status?: MissionAssertionStatus;
}
/** Input for updating a contract assertion */
export interface ContractAssertionUpdateInput {
title?: string;
assertion?: string;
status?: MissionAssertionStatus;
}
/** A feature-assertion link represents the association between a feature and an assertion */
export interface FeatureAssertionLink {
featureId: string;
assertionId: string;
createdAt: string;
}
/** Validation rollup for a milestone */
export interface MilestoneValidationRollup {
milestoneId: string;
totalAssertions: number;
passedCount: number;
failedCount: number;
blockedCount: number;
pendingCount: number;
state: MilestoneValidationState;
}
/** Validator run */
export interface MissionValidatorRun {
id: string;
featureId: string;
milestoneId: string;
sliceId: string;
status: ValidatorRunStatus;
triggerType: string;
implementationAttempt: number;
validatorAttempt: number;
summary?: string;
blockedReason?: string;
startedAt: string;
completedAt?: string;
createdAt: string;
updatedAt: string;
}
/** Loop state snapshot for a feature */
export interface MissionFeatureLoopSnapshot {
featureId: string;
feature: MissionFeature;
loopState: FeatureLoopState;
implementationAttemptCount: number;
validatorAttemptCount: number;
lastValidatorRunId?: string;
lastValidatorStatus?: ValidatorRunStatus;
generatedFromFeatureId?: string;
generatedFromRunId?: string;
retryBudgetRemaining: number;
}
export interface Slice {