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

@@ -71,7 +71,7 @@ function AppInner() {
const effectiveTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : [];
// Tasks hook with project context and search query
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks } = useTasks(
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, lastFetchTimeMs } = useTasks(
currentProject ? { projectId: currentProject.id, searchQuery: searchQuery || undefined } : { searchQuery: searchQuery || undefined }
);
@@ -372,6 +372,7 @@ function AppInner() {
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={handleOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
/>
</PageErrorBoundary>
);
@@ -398,6 +399,7 @@ function AppInner() {
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
searchQuery={searchQuery}
lastFetchTimeMs={lastFetchTimeMs}
/>
</PageErrorBoundary>
);
@@ -483,6 +485,7 @@ function AppInner() {
backgroundNeedsInput={bgNeedsInput}
onOpenBackgroundSession={handleOpenBackgroundSession}
onDismissBackgroundSession={bgDismiss}
lastFetchTimeMs={lastFetchTimeMs}
/>
)}
<MobileNavBar

View File

@@ -3494,6 +3494,178 @@ export function triageAllSliceFeatures(sliceId: string, projectId?: string): Pro
});
}
// ── Contract Assertion API ─────────────────────────────────────────────────────
/** Contract assertion status */
export type MissionAssertionStatus = "pending" | "passed" | "failed" | "blocked";
/** 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;
}
/** List assertions for a milestone, ordered by orderIndex */
export function fetchAssertions(milestoneId: string, projectId?: string): Promise<MissionContractAssertion[]> {
return api<MissionContractAssertion[]>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/assertions`, projectId));
}
/** Create a new assertion for a milestone */
export function createAssertion(milestoneId: string, input: ContractAssertionCreateInput, projectId?: string): Promise<MissionContractAssertion> {
return api<MissionContractAssertion>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/assertions`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Reorder assertions within a milestone */
export function reorderAssertions(milestoneId: string, orderedIds: string[], projectId?: string): Promise<void> {
return api<void>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/assertions/reorder`, projectId), {
method: "POST",
body: JSON.stringify({ orderedIds }),
});
}
/** Get a single assertion by ID */
export function fetchAssertion(assertionId: string, projectId?: string): Promise<MissionContractAssertion> {
return api<MissionContractAssertion>(withProjectId(`/missions/assertions/${encodeURIComponent(assertionId)}`, projectId));
}
/** Update an assertion */
export function updateAssertion(assertionId: string, updates: ContractAssertionUpdateInput, projectId?: string): Promise<MissionContractAssertion> {
return api<MissionContractAssertion>(withProjectId(`/missions/assertions/${encodeURIComponent(assertionId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete an assertion */
export function deleteAssertion(assertionId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/missions/assertions/${encodeURIComponent(assertionId)}`, projectId), {
method: "DELETE",
});
}
/** Link a feature to an assertion */
export function linkFeatureToAssertion(featureId: string, assertionId: string, projectId?: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/assertions/${encodeURIComponent(assertionId)}/link`, projectId), {
method: "POST",
});
}
/** Unlink a feature from an assertion */
export function unlinkFeatureFromAssertion(featureId: string, assertionId: string, projectId?: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/assertions/${encodeURIComponent(assertionId)}/unlink`, projectId), {
method: "POST",
});
}
/** List assertions linked to a feature */
export function fetchAssertionsForFeature(featureId: string, projectId?: string): Promise<MissionContractAssertion[]> {
return api<MissionContractAssertion[]>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/assertions`, projectId));
}
/** List features linked to an assertion */
export function fetchFeaturesForAssertion(assertionId: string, projectId?: string): Promise<MissionFeature[]> {
return api<MissionFeature[]>(withProjectId(`/missions/assertions/${encodeURIComponent(assertionId)}/features`, projectId));
}
/** Validation rollup for a milestone */
export interface MilestoneValidationRollup {
milestoneId: string;
totalAssertions: number;
passedCount: number;
failedCount: number;
blockedCount: number;
pendingCount: number;
state: "not_started" | "needs_coverage" | "ready" | "passed" | "failed" | "blocked";
}
/** Get milestone validation rollup */
export function fetchMilestoneValidation(milestoneId: string, projectId?: string): Promise<MilestoneValidationRollup> {
return api<MilestoneValidationRollup>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/validation`, projectId));
}
// ── Validation Loop API ───────────────────────────────────────────────────────
/** Loop state snapshot for a feature */
export interface MissionFeatureLoopSnapshot {
featureId: string;
feature: MissionFeature;
loopState: "idle" | "implementing" | "validating" | "needs_fix" | "passed" | "blocked";
implementationAttemptCount: number;
validatorAttemptCount: number;
lastValidatorRunId?: string;
lastValidatorStatus?: "running" | "passed" | "failed" | "blocked" | "error";
generatedFromFeatureId?: string;
generatedFromRunId?: string;
retryBudgetRemaining: number;
}
/** Validator run */
export interface MissionValidatorRun {
id: string;
featureId: string;
milestoneId: string;
sliceId: string;
status: "running" | "passed" | "failed" | "blocked" | "error";
triggerType: string;
implementationAttempt: number;
validatorAttempt: number;
summary?: string;
blockedReason?: string;
startedAt: string;
completedAt?: string;
createdAt: string;
updatedAt: string;
}
/** Trigger validation for a feature */
export function triggerValidation(featureId: string, projectId?: string): Promise<{ runId: string; featureId: string; status: string; triggerType: string; implementationAttempt: number; validatorAttempt: number; startedAt: string }> {
return api(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/validate`, projectId), {
method: "POST",
});
}
/** Get validation loop state for a feature */
export function fetchValidationLoopState(featureId: string, projectId?: string): Promise<MissionFeatureLoopSnapshot> {
return api<MissionFeatureLoopSnapshot>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/validation-loop`, projectId));
}
/** List validation runs for a feature */
export function fetchValidationRuns(featureId: string, options?: { limit?: number; offset?: number }, projectId?: string): Promise<MissionValidatorRun[]> {
const params = new URLSearchParams();
if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.offset !== undefined) params.set("offset", String(options.offset));
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return api<MissionValidatorRun[]>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/validation-runs${suffix}`, projectId));
}
/** Get a single validator run */
export function fetchValidationRun(runId: string, projectId?: string): Promise<MissionValidatorRun & { failures?: Array<{ id: string; assertionId: string; message?: string; expected?: string; actual?: string }> }> {
return api(withProjectId(`/missions/validation-runs/${encodeURIComponent(runId)}`, projectId));
}
/** Pause a mission (sets status to "blocked", in-flight tasks continue) */
export function pauseMission(missionId: string, projectId?: string): Promise<Mission> {
return api<Mission>(withProjectId(`/missions/${encodeURIComponent(missionId)}/pause`, projectId), {

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 {

View File

@@ -48,7 +48,7 @@ function deriveExecutorState(
/**
* Derive statistics from the task list.
*/
function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number): Pick<
function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number, lastFetchTimeMs?: number): Pick<
ExecutorStats,
"runningTaskCount" | "blockedTaskCount" | "stuckTaskCount" | "queuedTaskCount" | "inReviewCount"
> {
@@ -62,7 +62,7 @@ function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number): Pick<
switch (task.column) {
case "in-progress":
runningTaskCount++;
if (isTaskStuck(task, taskStuckTimeoutMs)) {
if (isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs)) {
stuckTaskCount++;
}
break;
@@ -101,7 +101,7 @@ function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number): Pick<
* - Derives executorState from globalPause and enginePaused flags
* - Returns ExecutorStats object with reactive updates
*/
export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTimeoutMs?: number): UseExecutorStatsResult {
export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTimeoutMs?: number, lastFetchTimeMs?: number): UseExecutorStatsResult {
const [apiData, setApiData] = useState<{
globalPause: boolean;
@@ -173,7 +173,7 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim
}, [refresh]);
// Derive stats from tasks and API data
const taskStats = deriveStatsFromTasks(tasks, taskStuckTimeoutMs);
const taskStats = deriveStatsFromTasks(tasks, taskStuckTimeoutMs, lastFetchTimeMs);
const executorState = deriveExecutorState(
apiData.globalPause,
apiData.enginePaused,

View File

@@ -0,0 +1,238 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ChatSession } from "@fusion/core";
import {
fetchChatSessions,
createChatSession,
fetchChatMessages,
streamChatResponse,
} from "../api";
export interface ChatMessageInfo {
id: string;
sessionId: string;
role: "user" | "assistant" | "system";
content: string;
thinkingOutput?: string | null;
createdAt: string;
}
export interface UseQuickChatReturn {
// Session state
activeSession: ChatSession | null;
sessionsLoading: boolean;
// Message state
messages: ChatMessageInfo[];
messagesLoading: boolean;
isStreaming: boolean;
streamingText: string;
streamingThinking: string;
// Operations
sendMessage: (content: string) => Promise<void>;
switchSession: (agentId: string) => Promise<void>;
loadMessages: () => Promise<void>;
}
/**
* Hook for the QuickChatFAB component.
* Provides chat session management and SSE streaming for real-time AI responses.
*/
export function useQuickChat(
projectId?: string,
addToast?: (msg: string, type?: "success" | "error") => void,
): UseQuickChatReturn {
// Session state
const [activeSession, setActiveSession] = useState<ChatSession | null>(null);
const [sessionsLoading, setSessionsLoading] = useState(false);
// Message state
const [messages, setMessages] = useState<ChatMessageInfo[]>([]);
const [messagesLoading, setMessagesLoading] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingText, setStreamingText] = useState("");
const [streamingThinking, setStreamingThinking] = useState("");
// Stream connection ref for cleanup
const streamRef = useRef<{ close: () => void } | null>(null);
// Track the current selected agent ID for session management
const currentAgentIdRef = useRef<string>("");
// Fetch existing sessions and find/create one for the given agent
const initializeSession = useCallback(
async (agentId: string) => {
if (!agentId) return;
setSessionsLoading(true);
try {
const data = await fetchChatSessions(projectId, "active");
// Find existing session for this agent
const existingSession = data.sessions.find((s) => s.agentId === agentId);
if (existingSession) {
setActiveSession(existingSession);
currentAgentIdRef.current = agentId;
} else {
// Create a new session for this agent
const newSession = await createChatSession({ agentId }, projectId);
setActiveSession(newSession.session);
currentAgentIdRef.current = agentId;
}
} catch (err) {
console.error("[useQuickChat] Failed to initialize session:", err);
addToast?.("Failed to initialize chat", "error");
} finally {
setSessionsLoading(false);
}
},
[projectId, addToast],
);
// Load messages for the active session
const loadMessages = useCallback(async () => {
if (!activeSession) return;
setMessagesLoading(true);
try {
const data = await fetchChatMessages(activeSession.id, { limit: 50 }, projectId);
// Reverse to show oldest first
setMessages(data.messages.reverse());
} catch (err) {
console.error("[useQuickChat] Failed to load messages:", err);
} finally {
setMessagesLoading(false);
}
}, [activeSession, projectId]);
// Load messages when session changes
useEffect(() => {
if (activeSession) {
void loadMessages();
} else {
setMessages([]);
}
}, [activeSession, loadMessages]);
// Switch to a different agent's session
const switchSession = useCallback(
async (agentId: string) => {
if (agentId === currentAgentIdRef.current) return;
// Close any existing stream
if (streamRef.current) {
streamRef.current.close();
streamRef.current = null;
}
// Reset streaming state
setStreamingText("");
setStreamingThinking("");
setIsStreaming(false);
// Initialize session for new agent
await initializeSession(agentId);
},
[initializeSession],
);
// Send a message using SSE streaming
const sendMessage = useCallback(
async (content: string) => {
if (!activeSession || !content.trim()) return;
// Close any existing stream
if (streamRef.current) {
streamRef.current.close();
streamRef.current = null;
}
// Optimistically add user message
const tempId = `temp-${Date.now()}`;
const userMessage: ChatMessageInfo = {
id: tempId,
sessionId: activeSession.id,
role: "user",
content,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
// Clear streaming state
setStreamingText("");
setStreamingThinking("");
setIsStreaming(true);
// Accumulate streaming text in local variables
let capturedText = "";
let capturedThinking = "";
const textHandlers = {
onThinking: (data: string) => {
capturedThinking += data;
setStreamingThinking(capturedThinking);
},
onText: (data: string) => {
capturedText += data;
setStreamingText(capturedText);
},
onDone: (data: { messageId: string }) => {
const assistantMessage: ChatMessageInfo = {
id: data.messageId || `msg-${Date.now()}`,
sessionId: activeSession.id,
role: "assistant",
content: capturedText,
thinkingOutput: capturedThinking || undefined,
createdAt: new Date().toISOString(),
};
setMessages((prev) => {
const withoutTemp = prev.filter((m) => m.id !== tempId);
return [...withoutTemp, assistantMessage];
});
setStreamingText("");
setStreamingThinking("");
setIsStreaming(false);
streamRef.current = null;
},
onError: (data: string) => {
// Remove the optimistic user message on error
setMessages((prev) => prev.filter((m) => m.id !== tempId));
setStreamingText("");
setStreamingThinking("");
setIsStreaming(false);
streamRef.current = null;
console.error("[useQuickChat] Stream error:", data);
addToast?.("Failed to send message", "error");
},
};
streamRef.current = streamChatResponse(activeSession.id, content, textHandlers, projectId);
},
[activeSession, projectId, addToast],
);
// Cleanup on unmount
useEffect(() => {
return () => {
if (streamRef.current) {
streamRef.current.close();
streamRef.current = null;
}
};
}, []);
return {
activeSession,
sessionsLoading,
messages,
messagesLoading,
isStreaming,
streamingText,
streamingThinking,
sendMessage,
switchSession,
loadMessages,
};
}

View File

@@ -56,6 +56,9 @@ export function useTasks(options?: UseTasksOptions) {
const lastVisibilityRefreshRef = useRef<number>(0);
const searchQueryRef = useRef(searchQuery);
const refreshTasksRef = useRef<typeof refreshTasks>(null!);
// Tracks when task data was last confirmed fresh by the server.
// Used to prevent false positives in stuck detection when tab has been in background.
const lastFetchTimeMs = useRef<number | undefined>(undefined);
tasksRef.current = tasks;
searchQueryRef.current = searchQuery;
@@ -72,6 +75,8 @@ export function useTasks(options?: UseTasksOptions) {
return;
}
setTasks(fetchedTasks.map(normalizeTask));
// Record when we received fresh server data for stuck detection
lastFetchTimeMs.current = Date.now();
} catch {
if (fetchVersionRef.current !== requestVersion) {
return;
@@ -187,6 +192,8 @@ export function useTasks(options?: UseTasksOptions) {
t.id === normalizedTask.id ? { ...normalizedTask, column: to } : t
)
);
// Record when we received fresh server data for stuck detection
lastFetchTimeMs.current = Date.now();
};
const handleUpdated = (e: MessageEvent) => {
@@ -222,6 +229,8 @@ export function useTasks(options?: UseTasksOptions) {
return incoming;
})
);
// Record when we received fresh server data for stuck detection
lastFetchTimeMs.current = Date.now();
};
const handleDeleted = (e: MessageEvent) => {
@@ -394,5 +403,5 @@ export function useTasks(options?: UseTasksOptions) {
return normalized;
}, [projectId]);
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived };
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived, lastFetchTimeMs: lastFetchTimeMs.current };
}

View File

@@ -212,6 +212,36 @@
--autopilot-completing-bg: color-mix(in srgb, #a855f7 15%, transparent);
--autopilot-completing-text: #a855f7;
/* === Loop State Colors === */
--loop-idle-bg: var(--bg-tertiary);
--loop-idle-text: var(--text-secondary);
--loop-idle-indicator: var(--text-dim);
--loop-implementing-bg: color-mix(in srgb, var(--in-review) 15%, transparent);
--loop-implementing-text: var(--in-review);
--loop-implementing-indicator: var(--in-review);
--loop-validating-bg: color-mix(in srgb, var(--color-warning) 15%, transparent);
--loop-validating-text: var(--color-warning);
--loop-validating-indicator: var(--color-warning);
--loop-needs-fix-bg: color-mix(in srgb, #f97316 15%, transparent);
--loop-needs-fix-text: #f97316;
--loop-needs-fix-indicator: #f97316;
--loop-passed-bg: color-mix(in srgb, var(--color-success) 15%, transparent);
--loop-passed-text: var(--color-success);
--loop-passed-indicator: var(--color-success);
--loop-blocked-bg: color-mix(in srgb, var(--color-error) 15%, transparent);
--loop-blocked-text: var(--color-error);
--loop-blocked-indicator: var(--color-error);
/* === Assertion Status Colors === */
--assertion-pending-bg: color-mix(in srgb, var(--triage) 15%, transparent);
--assertion-pending-text: var(--triage);
--assertion-passed-bg: color-mix(in srgb, var(--color-success) 15%, transparent);
--assertion-passed-text: var(--color-success);
--assertion-failed-bg: color-mix(in srgb, var(--color-error) 15%, transparent);
--assertion-failed-text: var(--color-error);
--assertion-blocked-bg: color-mix(in srgb, var(--color-warning) 15%, transparent);
--assertion-blocked-text: var(--color-warning);
/* === Interview Modal Icon Colors === */
--icon-milestone: var(--triage);
--icon-slice: var(--in-review);
@@ -13990,6 +14020,7 @@ html .column.drag-over * {
position: relative;
flex: 1;
min-width: 0;
width: 100%;
}
/* Quick Entry Box expand button - bottom-right of textarea */
@@ -20516,6 +20547,23 @@ html .column.drag-over * {
white-space: nowrap;
}
/* Validation coverage bar in milestone header */
.mission-milestone__coverage-bar {
flex: 1;
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
min-width: 40px;
max-width: 100px;
}
.mission-milestone__coverage-bar-fill {
height: 100%;
border-radius: 2px;
transition: width var(--transition-normal);
}
.mission-milestone__actions {
display: flex;
align-items: center;
@@ -20683,6 +20731,244 @@ html .column.drag-over * {
margin-top: var(--space-sm);
}
/* ── Loop State Indicator ── */
.mission-loop-state {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
flex-shrink: 0;
}
.mission-loop-state--implementing {
animation: loop-pulse 1.5s ease-in-out infinite;
}
.mission-loop-state--validating {
animation: loop-spin 1s linear infinite;
}
.mission-loop-state--needs_fix {
color: var(--color-warning);
}
.mission-loop-state--passed {
color: var(--color-success);
}
.mission-loop-state--blocked {
color: var(--color-error);
}
@keyframes loop-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes loop-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* ── Assertions Panel ── */
.mission-assertions {
margin-top: var(--space-md);
padding-top: var(--space-md);
border-top: 1px solid var(--border);
}
.mission-assertions__header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.mission-assertions__title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.mission-assertions__list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.mission-assertions__empty {
padding: var(--space-md);
text-align: center;
font-size: 12px;
color: var(--text-dim);
}
/* ── Single Assertion ── */
.mission-assertion {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--space-sm);
}
.mission-assertion__header {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.mission-assertion__title {
flex: 1;
font-size: 13px;
font-weight: 500;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.mission-assertion__body {
margin-top: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--border);
}
.mission-assertion__text {
font-size: 12px;
color: var(--text-secondary);
white-space: pre-wrap;
line-height: 1.5;
margin-bottom: var(--space-sm);
}
.mission-assertion__linked-count {
font-size: 11px;
color: var(--text-dim);
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 1px 6px;
flex-shrink: 0;
}
.mission-assertion__linked-features {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.mission-assertion__linked-features-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.mission-assertion__linked-features-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.mission-assertion__feature-picker {
position: relative;
}
.mission-assertion__feature-picker-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-xs);
}
.mission-assertion__feature-picker-empty {
font-size: 12px;
color: var(--text-dim);
padding: var(--space-xs);
text-align: center;
}
.mission-assertion__feature-picker-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background-color 0.1s;
}
.mission-assertion__feature-picker-item:hover:not(:disabled) {
background: var(--surface-hover);
}
.mission-assertion__feature-picker-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mission-assertion__feature-picker-title {
font-size: 12px;
color: var(--text);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mission-assertion__linked-empty {
font-size: 12px;
color: var(--text-dim);
font-style: italic;
}
.mission-assertion__linked-feature {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) 0;
border-bottom: 1px solid var(--border-subtle);
}
.mission-assertion__linked-feature:last-child {
border-bottom: none;
}
.mission-assertion__linked-feature-title {
flex: 1;
font-size: 12px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Validate Button ── */
.mission-icon-btn--validate {
color: var(--color-warning);
}
/* ── Features (nested inside slices) ── */
.mission-features {
display: flex;
@@ -20763,6 +21049,218 @@ html .column.drag-over * {
background: rgba(88, 166, 255, 0.2);
}
.mission-feature__lineage {
display: inline-flex;
align-items: center;
font-size: 11px;
color: #f97316;
background: rgba(249, 115, 22, 0.1);
padding: 1px 6px;
border-radius: var(--radius-sm);
}
.mission-feature__retry-budget {
font-size: 11px;
color: var(--text-dim);
}
.mission-feature__expand {
background: none;
border: none;
padding: 2px;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
flex-shrink: 0;
}
.mission-feature__expand:hover {
color: var(--text);
}
/* Run History */
.mission-feature__run-history {
margin-top: var(--space-sm);
padding: var(--space-sm);
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.mission-feature__run-history-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-sm);
}
.mission-feature__run-history-title {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.mission-run {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin-bottom: var(--space-xs);
overflow: hidden;
}
.mission-run:last-child {
margin-bottom: 0;
}
.mission-run__header {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
cursor: pointer;
font-size: 12px;
}
.mission-run__header:hover {
background: var(--card-hover);
}
.mission-run__status {
flex-shrink: 0;
}
.mission-run__status--passed {
background: var(--loop-passed-bg) !important;
color: var(--loop-passed-text) !important;
}
.mission-run__status--failed {
background: var(--loop-needs-fix-bg) !important;
color: var(--loop-needs-fix-text) !important;
}
.mission-run__status--blocked {
background: var(--loop-blocked-bg) !important;
color: var(--loop-blocked-text) !important;
}
.mission-run__status--running {
background: var(--loop-validating-bg) !important;
color: var(--loop-validating-text) !important;
}
.mission-run__status--error {
background: var(--color-error) !important;
color: white !important;
}
.mission-run__time {
color: var(--text-muted);
font-size: 11px;
}
.mission-run__duration {
color: var(--text-dim);
font-size: 11px;
}
.mission-run__trigger {
color: var(--text-dim);
font-size: 10px;
background: var(--bg-tertiary);
padding: 1px 4px;
border-radius: var(--radius-sm);
}
.mission-run__details {
padding: var(--space-sm);
border-top: 1px solid var(--border);
background: var(--bg-tertiary);
}
.mission-run__summary {
font-size: 12px;
color: var(--text);
margin: 0 0 var(--space-xs);
}
.mission-run__blocked-reason {
font-size: 12px;
color: var(--color-warning);
margin: 0 0 var(--space-xs);
}
.mission-run__failures {
margin-top: var(--space-sm);
}
.mission-run__failures-title {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
display: block;
margin-bottom: var(--space-xs);
}
.mission-run__failure {
background: rgba(248, 81, 73, 0.1);
border: 1px solid rgba(248, 81, 73, 0.2);
border-radius: var(--radius-sm);
padding: var(--space-xs) var(--space-sm);
margin-bottom: var(--space-xs);
font-size: 11px;
}
.mission-run__failure-message {
color: var(--color-error);
display: block;
}
.mission-run__failure-expected,
.mission-run__failure-actual {
color: var(--text-muted);
display: block;
font-family: var(--font-mono);
font-size: 10px;
margin-top: 2px;
}
.mission-run__no-failures {
font-size: 11px;
color: var(--text-dim);
font-style: italic;
margin: 0;
}
.mission-run-history__empty {
font-size: 12px;
color: var(--text-dim);
text-align: center;
padding: var(--space-sm);
}
/* Assertion coverage bar */
.mission-assertions__coverage-bar {
flex: 1;
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
min-width: 40px;
max-width: 100px;
}
.mission-assertions__coverage-bar-fill {
height: 100%;
border-radius: 2px;
transition: width var(--transition-normal);
}
/* ================================================================
Responsive — Mission Manager on mobile (≤768px)
================================================================ */
@@ -20989,6 +21487,44 @@ html .column.drag-over * {
.mission-confirm-panel__content input[type="text"] {
width: 100%;
}
/* Run history responsive at 375px */
.mission-feature__run-history {
padding: var(--space-xs);
}
.mission-feature__run-history-header {
margin-bottom: var(--space-xs);
}
.mission-run {
margin-bottom: var(--space-xs);
}
.mission-run__header {
flex-wrap: wrap;
gap: var(--space-xs);
}
.mission-run__details {
padding: var(--space-xs);
}
.mission-run__failure {
padding: var(--space-xs);
}
/* Assertions coverage bar responsive */
.mission-assertions__coverage-bar {
min-width: 30px;
max-width: 60px;
}
/* Feature lineage and retry budget wrap on narrow screens */
.mission-feature__lineage,
.mission-feature__retry-budget {
font-size: 10px;
}
}
/* ── Workflow Results ── */

View File

@@ -104,6 +104,98 @@ describe("isTaskStuck", () => {
const task = createTask({ updatedAt: boundary });
expect(isTaskStuck(task, 600000)).toBe(false);
});
describe("dataAsOfMs parameter (freshness-aware stuck detection)", () => {
it("uses dataAsOfMs instead of Date.now() when provided", () => {
// Task updatedAt is 11 minutes ago
const taskUpdatedAt = new Date(Date.now() - 11 * 60 * 1000).toISOString();
const task = createTask({ updatedAt: taskUpdatedAt });
// dataAsOfMs is 5 minutes ago (task was fresh 5 minutes ago)
const dataAsOfMs = Date.now() - 5 * 60 * 1000;
// 10 minute timeout
// With dataAsOfMs: 5 min - 11 min = -6 min < 10 min → NOT stuck
// Without dataAsOfMs: 0 min - 11 min = -11 min > 10 min → stuck
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(false);
});
it("falls back to Date.now() when dataAsOfMs is undefined", () => {
// Task updatedAt is 5 minutes ago
const taskUpdatedAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const task = createTask({ updatedAt: taskUpdatedAt });
// Without dataAsOfMs, should use Date.now() → NOT stuck (within 10 min timeout)
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("correctly identifies a task that would be stuck with Date.now() but not with dataAsOfMs", () => {
// Scenario: Tab was in background for 20 minutes
// Task was updated 10 minutes ago (relative to dataAsOfMs)
// dataAsOfMs represents "10 minutes ago" (when we fetched fresh data)
// Date.now() is "now" (20 minutes after the fetch)
//
// This simulates the background tab scenario:
// - User opened tab at T=0, fetched tasks
// - Tab went to background at T=0
// - User came back at T=20
// - dataAsOfMs = T=0 (when we last had fresh data)
// - Task was updated at T=-10 (10 minutes before fetch)
// - task.updatedAt represents T=-10
//
// Check: dataAsOfMs - updatedAt = 0 - (-10) = 10 min < 10 min timeout → NOT stuck
// Without dataAsOfMs: Date.now() - updatedAt = 20 - (-10) = 30 min > 10 min → STUCK (false positive!)
// In fake timers, we set Date.now() to a fixed point
// Let's say Date.now() = 1000 (representing "now")
// dataAsOfMs = 0 (representing 20 minutes before "now" in fake time)
// task.updatedAt = -600 (representing 10 minutes before dataAsOfMs)
vi.setSystemTime(new Date(1000)); // Date.now() = 1000
const dataAsOfMs = 0; // 20 minutes before Date.now() in this scenario
const taskUpdatedAt = new Date(-600000).toISOString(); // 10 minutes before dataAsOfMs
const task = createTask({ updatedAt: taskUpdatedAt });
// With dataAsOfMs: 0 - (-600000) = 600000ms = 10 min = timeout → NOT stuck (boundary)
// Without dataAsOfMs: 1000 - (-600000) = 601000ms > 10 min → STUCK
// The key test: with dataAsOfMs it should NOT be stuck even though Date.now() would say it is
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(false);
});
it("prevents false positive when tab was in background", () => {
// Simulate: Tab in background, data fetched 15 min ago
// Task.updatedAt is 12 min ago (stale from server perspective)
// taskStuckTimeoutMs = 10 min
// With fresh data (15 min ago): 15 - 12 = 3 min < 10 min → NOT stuck
// With stale Date.now(): 0 - 12 = 12 min > 10 min → STUCK (FALSE POSITIVE)
vi.setSystemTime(new Date(0)); // Date.now() = 0
const dataAsOfMs = -900000; // 15 minutes ago (in fake time)
const taskUpdatedAt = new Date(-720000).toISOString(); // 12 minutes ago (in fake time)
const task = createTask({ updatedAt: taskUpdatedAt });
// With dataAsOfMs: -900000 - (-720000) = -180000ms = -3 min < 10 min → NOT stuck
// Without dataAsOfMs: 0 - (-720000) = 720000ms = 12 min > 10 min → STUCK
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(false);
});
it("correctly identifies genuinely stuck tasks even with dataAsOfMs", () => {
// Task really is stuck: updatedAt is 15 min ago, timeout is 10 min
// With dataAsOfMs of 2 min ago: 2 - 15 = -13 min < 10 min → NOT stuck (hmm, this is a problem)
// Actually, dataAsOfMs should represent when we last got FRESH data from the server
// If dataAsOfMs = 2 min ago and task.updatedAt = 15 min ago, the task was stale
// even when we fetched it, because 2 - 15 = -13 min > 10 min timeout
vi.setSystemTime(new Date(0));
const dataAsOfMs = -120000; // 2 minutes ago
const taskUpdatedAt = new Date(-900000).toISOString(); // 15 minutes ago
const task = createTask({ updatedAt: taskUpdatedAt });
// With dataAsOfMs: -120000 - (-900000) = 780000ms = 13 min > 10 min → STUCK
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(true);
});
});
});
describe("countStuckTasks", () => {
@@ -152,4 +244,27 @@ describe("countStuckTasks", () => {
];
expect(countStuckTasks(tasks, 600000)).toBe(2);
});
describe("dataAsOfMs parameter (freshness-aware stuck detection)", () => {
it("passes dataAsOfMs through to isTaskStuck", () => {
// Task would be stuck with Date.now() but not with dataAsOfMs
vi.setSystemTime(new Date(0));
const dataAsOfMs = -900000; // 15 minutes ago
const taskUpdatedAt = new Date(-720000).toISOString(); // 12 minutes ago
const tasks = [createTask({ updatedAt: taskUpdatedAt })];
// With dataAsOfMs: -900000 - (-720000) = -180000ms = -3 min < 10 min → NOT stuck
expect(countStuckTasks(tasks, 600000, dataAsOfMs)).toBe(0);
});
it("counts tasks that are genuinely stuck even with dataAsOfMs", () => {
vi.setSystemTime(new Date(0));
const dataAsOfMs = -120000; // 2 minutes ago
const taskUpdatedAt = new Date(-900000).toISOString(); // 15 minutes ago
const tasks = [createTask({ updatedAt: taskUpdatedAt })];
// With dataAsOfMs: -120000 - (-900000) = 780000ms = 13 min > 10 min → STUCK
expect(countStuckTasks(tasks, 600000, dataAsOfMs)).toBe(1);
});
});
});

View File

@@ -9,11 +9,17 @@ const NON_STUCK_STATUSES = new Set(["failed", "stuck-killed"]);
* - It is in the "in-progress" column
* - A positive `taskStuckTimeoutMs` value is provided (stuck detection enabled)
* - Its `updatedAt` timestamp is older than `taskStuckTimeoutMs` milliseconds ago
* compared to `dataAsOfMs` (or `Date.now()` if `dataAsOfMs` is not provided)
*
* When `taskStuckTimeoutMs` is undefined, null, or 0, stuck detection is
* disabled and this function always returns false.
*
* The optional `dataAsOfMs` parameter represents when the task data was last
* confirmed fresh by the server. When provided, it is used instead of `Date.now()`
* for the comparison. This prevents false positives when the tab has been in
* the background and the task data is stale.
*/
export function isTaskStuck(task: Task, taskStuckTimeoutMs: number | undefined): boolean {
export function isTaskStuck(task: Task, taskStuckTimeoutMs: number | undefined, dataAsOfMs?: number): boolean {
if (task.column !== "in-progress") {
return false;
}
@@ -27,7 +33,8 @@ export function isTaskStuck(task: Task, taskStuckTimeoutMs: number | undefined):
}
const updatedAt = new Date(task.updatedAt).getTime();
const now = Date.now();
// Use dataAsOfMs if provided, otherwise fall back to current time
const now = dataAsOfMs ?? Date.now();
return now - updatedAt > taskStuckTimeoutMs;
}
@@ -35,15 +42,18 @@ export function isTaskStuck(task: Task, taskStuckTimeoutMs: number | undefined):
* Derive the stuck task count from a list of tasks using the given threshold.
*
* Returns 0 when stuck detection is disabled (undefined/0 threshold).
*
* The optional `dataAsOfMs` parameter is passed through to `isTaskStuck()` for
* freshness-aware stuck detection.
*/
export function countStuckTasks(tasks: Task[], taskStuckTimeoutMs: number | undefined): number {
export function countStuckTasks(tasks: Task[], taskStuckTimeoutMs: number | undefined, dataAsOfMs?: number): number {
if (!taskStuckTimeoutMs || taskStuckTimeoutMs <= 0) {
return 0;
}
let count = 0;
for (const task of tasks) {
if (isTaskStuck(task, taskStuckTimeoutMs)) {
if (isTaskStuck(task, taskStuckTimeoutMs, dataAsOfMs)) {
count++;
}
}