feat(FN-1465): merge fusion/fn-1465
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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 } : {})}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
238
packages/dashboard/app/hooks/useQuickChat.ts
Normal file
238
packages/dashboard/app/hooks/useQuickChat.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user