fix(dashboard): card timer shows total execution = timed events + workflow runtime

The task card timer chip previously fell back through several metrics
(timed duration → workflow runtime → wallclock), so cards showed only a
subset of execution time. For FN-2714 this rendered <1m on the card while
the stats tab reported >2m of workflow runtime.

The chip now reports the sum of [timing]-tagged log events and workflow
step runtime (matching the new "Total execution time" metric in the stats
panel), with live elapsed for in-progress workflow steps. When neither
metric is recorded, the chip is hidden rather than falling back to
wallclock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-27 14:23:54 -07:00
committed by gsxdsm
parent 777d6a1942
commit 7986b03207
9 changed files with 921 additions and 412 deletions

View File

@@ -5,12 +5,48 @@
justify-content: center;
}
.planning-modal *,
.planning-modal {
width: 90vw;
max-width: 640px;
min-height: 400px;
max-height: min(90vh, calc(100dvh - 2 * var(--overlay-padding-top, 10vh)));
scrollbar-color: var(--border) transparent;
scrollbar-width: thin;
}
.planning-modal *::-webkit-scrollbar,
.planning-modal::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.planning-modal *::-webkit-scrollbar-track,
.planning-modal::-webkit-scrollbar-track {
background: transparent;
}
.planning-modal *::-webkit-scrollbar-thumb,
.planning-modal::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: var(--radius-sm);
}
.planning-modal *::-webkit-scrollbar-thumb:hover,
.planning-modal::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.planning-modal *::-webkit-scrollbar-corner,
.planning-modal::-webkit-scrollbar-corner {
background: transparent;
}
.planning-modal {
width: min(95vw, 960px);
max-width: 95vw;
min-width: 360px;
height: 85vh;
min-height: 480px;
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
overflow: hidden;
resize: both;
}
.planning-modal .modal-header {
@@ -30,6 +66,225 @@
position: relative;
}
.planning-modal-body--split {
flex-direction: row;
}
.planning-detail {
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
/* Sidebar */
.planning-sidebar {
width: 260px;
flex-shrink: 0;
border-right: 1px solid var(--border);
background: var(--card);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.planning-sidebar-header {
padding: 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.planning-sidebar-new {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.planning-sidebar-new:hover {
background: var(--card-hover);
border-color: var(--todo);
}
.planning-sidebar-new.active {
background: color-mix(in srgb, var(--todo) 15%, transparent);
border-color: var(--todo);
color: var(--todo);
}
.planning-sidebar-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.planning-sidebar-empty {
padding: 16px 12px;
font-size: 12px;
text-align: center;
line-height: 1.5;
}
.planning-sidebar-item {
position: relative;
display: flex;
align-items: stretch;
border-radius: var(--radius-md);
transition: background var(--transition-fast);
}
.planning-sidebar-item:hover {
background: var(--card-hover);
}
.planning-sidebar-item.selected {
background: color-mix(in srgb, var(--todo) 18%, transparent);
}
.planning-sidebar-item.pending-delete {
background: color-mix(in srgb, var(--danger, #f85149) 15%, transparent);
}
.planning-sidebar-item-button {
flex: 1;
min-width: 0;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 8px 10px 10px;
background: none;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
.planning-sidebar-item-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.planning-sidebar-item-title {
font-size: 13px;
font-weight: 500;
color: var(--text);
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.planning-sidebar-item-meta {
font-size: 11px;
color: var(--text-muted);
display: flex;
gap: 4px;
align-items: center;
}
.planning-sidebar-status-icon {
flex-shrink: 0;
margin-top: 2px;
}
.planning-sidebar-status-generating { color: var(--todo); }
.planning-sidebar-status-awaiting { color: var(--triage); }
.planning-sidebar-status-complete { color: var(--success, #3fb950); }
.planning-sidebar-status-error { color: var(--danger, #f85149); }
.planning-sidebar-item-delete {
flex-shrink: 0;
width: 32px;
display: none;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-sm);
}
.planning-sidebar-item:hover .planning-sidebar-item-delete,
.planning-sidebar-item:focus-within .planning-sidebar-item-delete {
display: flex;
}
.planning-sidebar-item-delete:hover {
color: var(--danger, #f85149);
background: color-mix(in srgb, var(--danger, #f85149) 15%, transparent);
}
.planning-sidebar-confirm {
display: flex;
align-items: center;
gap: 4px;
padding: 0 8px;
}
.planning-mobile-back {
display: none;
background: none;
border: none;
color: var(--text);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-sm);
}
.planning-mobile-back:hover {
background: var(--card-hover);
}
/* Mobile: stack — only one pane visible at a time */
@media (max-width: 720px) {
.planning-modal-body--split {
flex-direction: column;
}
.planning-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border);
}
.planning-modal-body--show-detail .planning-sidebar {
display: none;
}
.planning-modal-body--show-list .planning-detail {
display: none;
}
.planning-modal-body--show-detail .planning-mobile-back {
display: inline-flex;
}
/* Always keep delete button visible on mobile (no hover) */
.planning-sidebar-item-delete {
display: flex;
}
}
.planning-error {
flex-shrink: 0;
margin: 24px 24px 0;
@@ -249,8 +504,8 @@
.planning-progress {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding-bottom: 16px;
gap: var(--space-xs);
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
@@ -286,15 +541,15 @@
}
.planning-question-scroll {
gap: 20px;
padding-top: var(--space-md);
gap: 16px;
padding-top: 0;
}
.planning-question-panel {
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px;
gap: 16px;
padding: 16px 20px 20px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);

View File

@@ -9,6 +9,8 @@ import {
createTaskFromPlanning,
connectPlanningStream,
fetchAiSession,
fetchAiSessions,
deleteAiSession,
parseConversationHistory,
startPlanningBreakdown,
createTasksFromPlanning,
@@ -19,13 +21,15 @@ import {
type SubtaskItem,
type ModelInfo,
type ConversationHistoryEntry,
type AiSessionSummary,
} from "../api";
import { subscribeSse } from "../sse-bus";
import {
savePlanningDescription,
getPlanningDescription,
clearPlanningDescription,
} from "../hooks/modalPersistence";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2, RefreshCw, Lock } from "lucide-react";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ConversationHistory } from "./ConversationHistory";
import { useSessionLock } from "../hooks/useSessionLock";
@@ -128,6 +132,31 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
const trackedLockSessionRef = useRef<string | null>(null);
// Sidebar list state
const [planningSessions, setPlanningSessions] = useState<AiSessionSummary[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(resumeSessionId ?? null);
// Mobile: when the modal is narrow, only one pane is visible at a time.
// `mobileShowDetail` toggles between list (false) and detail (true).
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(resumeSessionId));
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const resetDetailState = useCallback(() => {
setInitialPlan("");
setView({ type: "initial" });
setError(null);
setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setIsReconnecting(false);
setIsRetrying(false);
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
setLockSessionId(null);
}, []);
const planningSelectionValue = getModelSelectionValue(planningModelProvider, planningModelId);
const getModelBadgeLabel = useCallback(
@@ -333,6 +362,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const { sessionId } = await startPlanningStreaming(plan.trim(), projectId, modelOverride);
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
setSelectedSessionId(sessionId);
connectToPlanningStream(sessionId);
setResponseHistory([]);
@@ -389,17 +419,30 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}
}, [isOpen, initialPlanProp, view.type, handleStartPlanning, projectId]);
// Resume a persisted background session
useEffect(() => {
if (!isOpen || !resumeSessionId || view.type !== "initial") return;
let cancelled = false;
(async () => {
try {
const session = await fetchAiSession(resumeSessionId);
if (cancelled || !session) return;
// Load a specific persisted session into the right pane.
const loadSession = useCallback(
async (sessionId: string) => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
currentSessionIdRef.current = resumeSessionId;
setLockSessionId(resumeSessionId);
setError(null);
setStreamingOutput("");
setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null);
setIsRetrying(false);
setView({ type: "loading" });
try {
const session = await fetchAiSession(sessionId);
if (!session) {
setError("Session not found");
setView({ type: "initial" });
return;
}
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setResponseHistory(
@@ -413,34 +456,163 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (session.status === "awaiting_input" && session.currentQuestion) {
clearPlanningDescription(projectId);
const question = JSON.parse(session.currentQuestion);
setView({ type: "question", session: { sessionId: resumeSessionId, currentQuestion: question, summary: null } });
setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null } });
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
// Connect to stream for real-time updates (e.g., thinking output, next question)
// The server will emit a catch-up question event if the client missed it
connectToPlanningStream(resumeSessionId);
connectToPlanningStream(sessionId);
} else if (session.status === "complete" && session.result) {
clearPlanningDescription(projectId);
const summary = JSON.parse(session.result);
setView({ type: "summary", session: { sessionId: resumeSessionId, currentQuestion: null, summary }, summary });
setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary });
setEditedSummary(summary);
} else if (session.status === "generating") {
setView({ type: "loading" });
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
connectToPlanningStream(resumeSessionId);
connectToPlanningStream(sessionId);
} else if (session.status === "error") {
setError(null);
setView({
type: "error",
session: { sessionId: resumeSessionId, currentQuestion: null, summary: null },
session: { sessionId, currentQuestion: null, summary: null },
errorMessage: session.error || "Session failed",
});
}
} catch {
setError("Failed to resume session");
setError("Failed to load session");
setView({ type: "initial" });
}
})();
return () => { cancelled = true; };
}, [connectToPlanningStream, isOpen, resumeSessionId, view.type, projectId]);
},
[connectToPlanningStream, projectId],
);
// Resume the externally-requested session when the modal first opens.
// (Selecting from the sidebar uses handleSelectSession instead.)
useEffect(() => {
if (!isOpen || !resumeSessionId) return;
if (currentSessionIdRef.current === resumeSessionId) return;
setSelectedSessionId(resumeSessionId);
setMobileShowDetail(true);
void loadSession(resumeSessionId);
}, [isOpen, resumeSessionId, loadSession]);
// Load + maintain the planning sessions list (sidebar).
const refreshSessionsList = useCallback(async () => {
setSessionsLoading(true);
try {
const all = await fetchAiSessions(projectId);
const planning = all
.filter((s) => s.type === "planning")
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
setPlanningSessions(planning);
} catch {
// Best-effort: list errors should not block the modal
} finally {
setSessionsLoading(false);
}
}, [projectId]);
useEffect(() => {
if (!isOpen) return;
void refreshSessionsList();
}, [isOpen, refreshSessionsList]);
// SSE subscription keeps the list live (mirrors useBackgroundSessions, but
// unfiltered by status so completed/errored sessions stay visible).
useEffect(() => {
if (!isOpen) return;
const params = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const handleUpdated = (e: MessageEvent) => {
try {
const updated = JSON.parse(e.data) as AiSessionSummary;
if (updated.type !== "planning") return;
setPlanningSessions((prev) => {
const idx = prev.findIndex((s) => s.id === updated.id);
const next = idx >= 0 ? [...prev.slice(0, idx), updated, ...prev.slice(idx + 1)] : [updated, ...prev];
return next.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
});
} catch {
// ignore malformed payload
}
};
const handleDeleted = (e: MessageEvent) => {
try {
const id = JSON.parse(e.data) as string;
setPlanningSessions((prev) => prev.filter((s) => s.id !== id));
} catch {
// ignore malformed payload
}
};
return subscribeSse(`/api/events${params}`, {
events: {
"ai_session:updated": handleUpdated,
"ai_session:deleted": handleDeleted,
},
});
}, [isOpen, projectId]);
// Sidebar handlers
const handleSelectSession = useCallback(
(sessionId: string) => {
if (selectedSessionId === sessionId) {
setMobileShowDetail(true);
return;
}
setSelectedSessionId(sessionId);
setMobileShowDetail(true);
void loadSession(sessionId);
},
[loadSession, selectedSessionId],
);
const handleNewSession = useCallback(() => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
resetDetailState();
setSelectedSessionId(null);
setMobileShowDetail(true);
}, [resetDetailState]);
const handleBackToList = useCallback(() => {
setMobileShowDetail(false);
}, []);
const handleDeleteSession = useCallback(
async (sessionId: string) => {
const isActiveServerSession = (status: AiSessionSummary["status"]) =>
status === "generating" || status === "awaiting_input";
const target = planningSessions.find((s) => s.id === sessionId);
// Cancel an in-flight server session before deleting so the engine stops
// generating; for terminal sessions skip the cancel call.
if (target && isActiveServerSession(target.status)) {
try {
await cancelPlanning(sessionId, projectId, sessionTabId);
} catch {
// best-effort
}
}
try {
await deleteAiSession(sessionId);
} catch {
// best-effort: SSE will reconcile if the delete actually succeeded
}
setPlanningSessions((prev) => prev.filter((s) => s.id !== sessionId));
if (selectedSessionId === sessionId) {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
resetDetailState();
setSelectedSessionId(null);
setMobileShowDetail(false);
}
setPendingDeleteId(null);
},
[planningSessions, projectId, resetDetailState, selectedSessionId, sessionTabId],
);
// Reset hasAutoStarted when modal closes
useEffect(() => {
@@ -527,49 +699,21 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
onClose();
}, [onClose]);
const handleCancel = useCallback(() => {
// Determine the active session ID to abandon
let activeSessionId: string | null = null;
if (view.type === "question" || view.type === "summary" || view.type === "error") {
activeSessionId = view.session.sessionId;
} else if (view.type === "breakdown") {
activeSessionId = view.sessionId;
} else if (view.type === "loading") {
// During loading, the session ID is stored in the ref
activeSessionId = currentSessionIdRef.current;
}
// Save to localStorage BEFORE any cleanup (preserve for re-entry)
if (initialPlan) {
// Close the modal without abandoning the active server session. Sessions
// remain in the list and can be resumed later. Only an explicit Delete
// (from the sidebar) cancels and removes a session.
const handleClose = useCallback(() => {
// Save the in-progress draft so the next open restores it.
if (initialPlan && view.type === "initial") {
savePlanningDescription(initialPlan, projectId);
}
// Always close the stream connection
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
// Explicitly abandon the session on the server to prevent zombie sessions
if (activeSessionId) {
void cancelPlanning(activeSessionId, projectId, sessionTabId).catch(() => {
// Best-effort: cancellation failures should not block UI reset
});
}
setInitialPlan("");
setView({ type: "initial" });
setError(null);
setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setIsReconnecting(false);
setIsRetrying(false);
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
}, [initialPlan, onClose, projectId, sessionTabId, view]);
}, [initialPlan, onClose, projectId, view.type]);
// Handle escape key to close
useEffect(() => {
@@ -577,13 +721,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleCancel();
handleClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, handleCancel]);
}, [isOpen, handleClose]);
const handleSubmitResponse = useCallback(
async (responses: QuestionResponse) => {
@@ -724,12 +868,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try {
const task = await createTaskFromPlanning(view.session.sessionId, editedSummary ?? undefined, projectId);
onTaskCreated(task);
handleCancel();
handleClose();
} catch (err) {
setError(getErrorMessage(err) || "Failed to create task");
setView({ type: "summary", session: view.session, summary: view.summary });
}
}, [editedSummary, view, projectId, onTaskCreated, handleCancel]);
}, [editedSummary, view, projectId, onTaskCreated, handleClose]);
const handleStartBreakdown = useCallback(async () => {
if (view.type !== "summary") return;
@@ -809,10 +953,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && handleCancel()} role="dialog" aria-modal="true">
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && handleClose()} role="dialog" aria-modal="true">
<div className="modal modal-lg planning-modal">
<div className="modal-header">
<div className="detail-title-row">
{mobileShowDetail && (
<button
className="modal-back planning-mobile-back"
onClick={handleBackToList}
aria-label="Back to sessions"
title="Back to sessions"
>
<ChevronLeft size={18} />
</button>
)}
<Lightbulb size={20} className="icon-triage" />
<h3>Planning Mode</h3>
</div>
@@ -827,13 +981,30 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<Minimize2 size={16} />
</button>
)}
<button className="modal-close" onClick={handleCancel} aria-label="Close">
<button className="modal-close" onClick={handleClose} aria-label="Close">
<X size={20} />
</button>
</div>
</div>
<div className="planning-modal-body">
<div
className={`planning-modal-body planning-modal-body--split ${
mobileShowDetail ? "planning-modal-body--show-detail" : "planning-modal-body--show-list"
}`}
>
<PlanningSessionList
sessions={planningSessions}
loading={sessionsLoading}
selectedSessionId={selectedSessionId}
pendingDeleteId={pendingDeleteId}
onSelectSession={handleSelectSession}
onNewSession={handleNewSession}
onRequestDelete={setPendingDeleteId}
onConfirmDelete={(id) => void handleDeleteSession(id)}
onCancelDelete={() => setPendingDeleteId(null)}
/>
<div className="planning-detail">
{error && <div className="form-error planning-error">{error}</div>}
{isReconnecting && <div className="form-hint text-muted">Reconnecting</div>}
{activeInAnotherTab && (
@@ -996,7 +1167,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
<span className="icon-ml-6">{isRetrying ? "Retrying..." : "Retry"}</span>
</button>
<button className="btn" onClick={handleCancel} disabled={isRetrying}>Dismiss</button>
<button className="btn" onClick={handleClose} disabled={isRetrying}>Dismiss</button>
</div>
</div>
</div>
@@ -1061,6 +1232,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}}
/>
)}
</div>
{isLockedByOther && (
<div className="session-lock-overlay" data-testid="session-lock-overlay">
@@ -1804,3 +1976,159 @@ function BreakdownView({
</div>
);
}
// ── PlanningSessionList (sidebar) ──────────────────────────────────────────
interface PlanningSessionListProps {
sessions: AiSessionSummary[];
loading: boolean;
selectedSessionId: string | null;
pendingDeleteId: string | null;
onSelectSession: (id: string) => void;
onNewSession: () => void;
onRequestDelete: (id: string) => void;
onConfirmDelete: (id: string) => void;
onCancelDelete: () => void;
}
function PlanningSessionList({
sessions,
loading,
selectedSessionId,
pendingDeleteId,
onSelectSession,
onNewSession,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
}: PlanningSessionListProps) {
return (
<aside className="planning-sidebar" aria-label="Planning sessions">
<div className="planning-sidebar-header">
<button
className={`planning-sidebar-new ${selectedSessionId === null ? "active" : ""}`}
onClick={onNewSession}
type="button"
>
<MessageSquarePlus size={16} />
<span>New session</span>
</button>
</div>
<div className="planning-sidebar-list">
{sessions.length === 0 && !loading && (
<div className="planning-sidebar-empty text-muted">
No saved sessions yet. Start one on the right to see it here.
</div>
)}
{sessions.map((session) => {
const isSelected = session.id === selectedSessionId;
const isPendingDelete = pendingDeleteId === session.id;
return (
<div
key={session.id}
className={`planning-sidebar-item ${isSelected ? "selected" : ""} ${isPendingDelete ? "pending-delete" : ""}`}
>
<button
type="button"
className="planning-sidebar-item-button"
onClick={() => onSelectSession(session.id)}
>
<PlanningSessionStatusIcon status={session.status} />
<span className="planning-sidebar-item-body">
<span className="planning-sidebar-item-title">
{session.title || "Untitled session"}
</span>
<span className="planning-sidebar-item-meta">
<PlanningSessionStatusLabel status={session.status} />
<span aria-hidden> · </span>
<span>{formatRelativeTime(session.updatedAt)}</span>
</span>
</span>
</button>
{isPendingDelete ? (
<div className="planning-sidebar-confirm">
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => onConfirmDelete(session.id)}
>
Delete
</button>
<button
type="button"
className="btn btn-sm"
onClick={onCancelDelete}
>
Cancel
</button>
</div>
) : (
<button
type="button"
className="planning-sidebar-item-delete"
onClick={(e) => {
e.stopPropagation();
onRequestDelete(session.id);
}}
aria-label="Delete session"
title="Delete session"
>
<Trash2 size={14} />
</button>
)}
</div>
);
})}
</div>
</aside>
);
}
function PlanningSessionStatusIcon({ status }: { status: AiSessionSummary["status"] }) {
switch (status) {
case "generating":
return <Loader2 size={14} className="spin planning-sidebar-status-icon planning-sidebar-status-generating" />;
case "awaiting_input":
return <HelpCircle size={14} className="planning-sidebar-status-icon planning-sidebar-status-awaiting" />;
case "complete":
return <CheckCircle size={14} className="planning-sidebar-status-icon planning-sidebar-status-complete" />;
case "error":
return <AlertCircle size={14} className="planning-sidebar-status-icon planning-sidebar-status-error" />;
default:
return <Clock size={14} className="planning-sidebar-status-icon" />;
}
}
function PlanningSessionStatusLabel({ status }: { status: AiSessionSummary["status"] }) {
switch (status) {
case "generating":
return <span>Generating</span>;
case "awaiting_input":
return <span>Needs input</span>;
case "complete":
return <span>Complete</span>;
case "error":
return <span>Error</span>;
default:
return <span>{status}</span>;
}
}
function formatRelativeTime(iso: string): string {
const ms = Date.now() - Date.parse(iso);
if (!Number.isFinite(ms) || ms < 0) return "";
const sec = Math.floor(ms / 1000);
if (sec < 60) return "just now";
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const days = Math.floor(hr / 24);
if (days < 7) return `${days}d ago`;
const weeks = Math.floor(days / 7);
if (weeks < 4) return `${weeks}w ago`;
return new Date(iso).toLocaleDateString();
}

View File

@@ -103,17 +103,6 @@ function parseTimestampToMs(value?: string): number | null {
return Number.isFinite(parsed) ? parsed : null;
}
function getInProgressTimeIndicatorStartMs(task: Task): number | null {
const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt;
const parsed = parseTimestampToMs(timestamp);
if (parsed == null) return null;
const now = Date.now();
if (parsed > now) return null;
return parsed;
}
function getDoneCompletionMs(task: Task): number | null {
const completionMs = parseTimestampToMs(task.columnMovedAt ?? task.updatedAt);
if (completionMs == null) return null;
@@ -124,32 +113,40 @@ function getDoneCompletionMs(task: Task): number | null {
return completionMs;
}
function getDoneProcessingStartMs(task: Task, completionMs: number): number | null {
const startCandidates = [task.createdAt]
.map(parseTimestampToMs)
.filter((value): value is number => value != null);
const validStart = startCandidates.find((startMs) => startMs <= completionMs);
return validStart ?? null;
}
function getDoneWorkflowRuntimeMs(task: Task): number | null {
// Mirrors summarizeWorkflowTiming in TaskTokenStatsPanel: completed steps use
// completedAt-startedAt; in-progress steps contribute live elapsed (now-startedAt).
function getWorkflowRuntimeMs(task: Task, nowMs: number): number | null {
const results = task.workflowStepResults;
if (!results || results.length === 0) return null;
let total = 0;
let counted = 0;
for (const step of results) {
if (!step.startedAt || !step.completedAt) continue;
if (!step.startedAt) continue;
const startedMs = parseTimestampToMs(step.startedAt);
const completedMs = parseTimestampToMs(step.completedAt);
if (startedMs == null || completedMs == null || completedMs < startedMs) continue;
total += completedMs - startedMs;
if (startedMs == null) continue;
let endMs: number;
if (step.completedAt) {
const completedMs = parseTimestampToMs(step.completedAt);
if (completedMs == null || completedMs < startedMs) continue;
endMs = completedMs;
} else {
endMs = Math.max(startedMs, nowMs);
}
total += endMs - startedMs;
counted += 1;
}
return counted > 0 ? total : null;
}
function getInstrumentedDurationMs(task: Task, nowMs: number): number | null {
const timed = getTimedDurationMs(task.log);
const workflow = getWorkflowRuntimeMs(task, nowMs);
if (timed == null && workflow == null) return null;
return (timed ?? 0) + (workflow ?? 0);
}
function formatElapsedDuration(elapsedMs: number): string {
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return "";
@@ -659,8 +656,10 @@ function TaskCardComponent({
return;
}
const startMs = getInProgressTimeIndicatorStartMs(task);
if (startMs == null) {
const hasInProgressStep = (task.workflowStepResults ?? []).some(
(step) => step.startedAt && !step.completedAt,
);
if (!hasInProgressStep) {
return;
}
@@ -670,98 +669,47 @@ function TaskCardComponent({
}, LIVE_TIME_INDICATOR_POLL_MS);
return () => window.clearInterval(interval);
}, [task.column, task.columnMovedAt, task.updatedAt, task.createdAt]);
}, [task.column, task.workflowStepResults]);
const timeIndicator = useMemo(() => {
if (!TIME_INDICATOR_COLUMNS.has(task.column)) {
return null;
}
if (task.column === "in-progress") {
const timedDurationMs = getTimedDurationMs(task.log);
if (timedDurationMs != null) {
const elapsedLabel = formatElapsedDuration(timedDurationMs);
if (elapsedLabel) {
return {
label: elapsedLabel,
title: `Timed duration ${elapsedLabel}`,
ariaLabel: `Timed duration ${elapsedLabel}`,
};
}
}
const startMs = getInProgressTimeIndicatorStartMs(task);
if (startMs == null) {
return null;
}
const elapsedLabel = formatElapsedDuration(timeIndicatorNowMs - startMs);
if (!elapsedLabel) {
return null;
}
return {
label: elapsedLabel,
title: `In progress since ${new Date(startMs).toLocaleString()}`,
ariaLabel: `Elapsed time ${elapsedLabel}. In progress since ${new Date(startMs).toLocaleString()}`,
};
}
// Done cards report the same "Timed duration" metric shown in the stats tab
// (sum of [timing]-tagged log events). Fall back to workflow step runtime,
// then to wallclock processing duration when no instrumentation exists.
const completionMs = getDoneCompletionMs(task);
if (completionMs == null) {
const instrumentedMs = getInstrumentedDurationMs(task, timeIndicatorNowMs);
if (instrumentedMs == null) {
return null;
}
const timedDurationMs = getTimedDurationMs(task.log);
if (timedDurationMs != null) {
const elapsedLabel = formatElapsedDuration(timedDurationMs);
if (!elapsedLabel) {
return null;
}
const completedAt = new Date(completionMs).toLocaleString();
return {
label: elapsedLabel,
title: `Timed duration ${elapsedLabel}. Completed ${completedAt}`,
ariaLabel: `Timed duration ${elapsedLabel}. Completed ${completedAt}`,
};
}
const workflowRuntimeMs = getDoneWorkflowRuntimeMs(task);
if (workflowRuntimeMs != null) {
const elapsedLabel = formatElapsedDuration(workflowRuntimeMs);
if (!elapsedLabel) {
return null;
}
const completedAt = new Date(completionMs).toLocaleString();
return {
label: elapsedLabel,
title: `Workflow runtime ${elapsedLabel}. Completed ${completedAt}`,
ariaLabel: `Workflow runtime ${elapsedLabel}. Completed ${completedAt}`,
};
}
const startMs = getDoneProcessingStartMs(task, completionMs);
if (startMs == null) {
return null;
}
const elapsedLabel = formatElapsedDuration(completionMs - startMs);
const elapsedLabel = formatElapsedDuration(instrumentedMs);
if (!elapsedLabel) {
return null;
}
if (task.column === "in-progress") {
return {
label: elapsedLabel,
title: `Execution time ${elapsedLabel}`,
ariaLabel: `Execution time ${elapsedLabel}`,
};
}
const completionMs = getDoneCompletionMs(task);
if (completionMs == null) {
return {
label: elapsedLabel,
title: `Execution time ${elapsedLabel}`,
ariaLabel: `Execution time ${elapsedLabel}`,
};
}
const completedAt = new Date(completionMs).toLocaleString();
return {
label: elapsedLabel,
title: `Processing took ${elapsedLabel}. Completed ${completedAt}`,
ariaLabel: `Completed processing duration ${elapsedLabel}. Completed ${completedAt}`,
title: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
ariaLabel: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
};
}, [task.column, task.columnMovedAt, task.updatedAt, task.createdAt, task.workflowStepResults, task.log, timeIndicatorNowMs]);
}, [task.column, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.log, timeIndicatorNowMs]);
useEffect(() => {
if (!hasGitHubBadge || !isInViewport) {

View File

@@ -140,6 +140,10 @@ export function TaskTokenStatsPanel({ tokenUsage, loading, task }: TaskTokenStat
<span className="task-token-stats-panel__label">Workflow runtime</span>
<span className="task-token-stats-panel__value">{formatDuration(workflowTiming.totalDurationMs)}</span>
</div>
<div className="task-token-stats-panel__metric" role="listitem">
<span className="task-token-stats-panel__label">Total execution time</span>
<span className="task-token-stats-panel__value">{formatDuration(totalTimingDurationMs + workflowTiming.totalDurationMs)}</span>
</div>
</div>
<dl className="task-token-stats-panel__timestamps">

View File

@@ -460,7 +460,6 @@ describe("PlanningModeModal", () => {
expect(blockMatch).toBeTruthy();
const maxHeightValue = blockMatch![1].trim();
expect(maxHeightValue).toContain("min(");
expect(maxHeightValue).toContain("calc(");
expect(maxHeightValue).toContain("100dvh");
expect(maxHeightValue).toContain("--overlay-padding-top");
@@ -1765,7 +1764,7 @@ describe("PlanningModeModal", () => {
expect(mockOnClose).toHaveBeenCalled();
});
it("closes active question session and abandons server session", async () => {
it("closes active question session WITHOUT abandoning the server session", async () => {
render(
<PlanningModeModal
isOpen={true}
@@ -1787,12 +1786,12 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByLabelText("Close"));
expect(mockConfirm).not.toHaveBeenCalled();
// Closing an active session should abandon it on the server
expect(mockCancelPlanning).toHaveBeenCalledTimes(1);
// Closing the modal should leave the server session intact so it stays in the sidebar list
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
it("closes summary view and abandons server session", async () => {
it("closes summary view WITHOUT abandoning the server session", async () => {
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onSummary?.(mockSummary);
@@ -1825,12 +1824,12 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByLabelText("Close"));
expect(mockConfirm).not.toHaveBeenCalled();
// Closing an active session should abandon it on the server
expect(mockCancelPlanning).toHaveBeenCalledTimes(1);
// Completed sessions remain available to resume; closing must not cancel them
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
it("closes via overlay and abandons server session", async () => {
it("closes via overlay WITHOUT abandoning the server session", async () => {
const { container } = render(
<PlanningModeModal
isOpen={true}
@@ -1854,12 +1853,12 @@ describe("PlanningModeModal", () => {
fireEvent.click(overlay!);
expect(mockConfirm).not.toHaveBeenCalled();
// Closing an active session should abandon it on the server
expect(mockCancelPlanning).toHaveBeenCalledTimes(1);
// Sessions persist in the sidebar; overlay click should not cancel
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
it("closes during loading state and abandons server session", async () => {
it("closes during loading state WITHOUT abandoning the server session", async () => {
mockConnectPlanningStream.mockImplementationOnce(() => ({
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
@@ -1886,8 +1885,8 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByLabelText("Close"));
expect(mockConfirm).not.toHaveBeenCalled();
// Closing an active session should abandon it on the server
expect(mockCancelPlanning).toHaveBeenCalledTimes(1);
// Loading state means the session is still being generated server-side; preserve it
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
@@ -1924,7 +1923,7 @@ describe("PlanningModeModal", () => {
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("disconnects SSE stream and abandons session on close", async () => {
it("disconnects the SSE stream on close (but keeps the server session)", async () => {
const closeSpy = vi.fn();
mockConnectPlanningStream.mockImplementationOnce(() => ({
@@ -1953,8 +1952,9 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByLabelText("Close"));
expect(closeSpy).toHaveBeenCalledTimes(1);
// Closing an active session should abandon it on the server
expect(mockCancelPlanning).toHaveBeenCalledTimes(1);
// The local SSE stream closes on modal close, but the server session is preserved
// for later resume from the sidebar list.
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
});

View File

@@ -455,17 +455,28 @@ describe("TaskCard", () => {
expect(actionsContainer?.contains(archiveBtn)).toBe(true);
});
it("shows timer chip for in-progress cards when timestamp fields exist", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T12:30:00.000Z"));
it("shows timer chip for in-progress cards summing workflow runtime + timed events", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
columnMovedAt: "2026-04-25T12:18:00.000Z",
updatedAt: "2026-04-25T12:10:00.000Z",
createdAt: "2026-04-25T12:00:00.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T12:00:00.000Z",
completedAt: "2026-04-25T12:08:00.000Z",
},
],
log: [
{
timestamp: "2026-04-25T12:09:00.000Z",
action: "[timing] llm_call in 240000ms",
outcome: "",
} as unknown as Task["log"][number],
],
})}
onOpenDetail={noop}
addToast={noop}
@@ -474,15 +485,12 @@ describe("TaskCard", () => {
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
// 8m workflow + 4m timed = 12m
expect(timer?.textContent).toContain("12m");
expect(timer?.getAttribute("title")).toContain("In progress since");
expect(timer?.getAttribute("aria-label")).toContain("Elapsed time 12m");
expect(timer?.getAttribute("title")).toContain("Execution time 12m");
});
it("shows fixed processing-duration timer chip for done cards", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
it("shows timer chip for done cards summing workflow runtime + timed events", () => {
const { container } = render(
<TaskCard
task={makeTask({
@@ -490,6 +498,23 @@ describe("TaskCard", () => {
columnMovedAt: "2026-04-25T15:00:00.000Z",
updatedAt: "2026-04-25T15:00:00.000Z",
createdAt: "2026-04-25T13:00:00.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T13:00:00.000Z",
completedAt: "2026-04-25T14:00:00.000Z",
},
],
log: [
{
timestamp: "2026-04-25T14:30:00.000Z",
action: "[timing] llm_call in 3600000ms",
outcome: "",
} as unknown as Task["log"][number],
],
})}
onOpenDetail={noop}
addToast={noop}
@@ -498,15 +523,13 @@ describe("TaskCard", () => {
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
// 1h workflow + 1h timed = 2h
expect(timer?.textContent).toContain("2h");
expect(timer?.getAttribute("title")).toContain("Processing took 2h");
expect(timer?.getAttribute("aria-label")).toContain("Completed processing duration 2h");
expect(timer?.getAttribute("title")).toContain("Execution time 2h");
expect(timer?.getAttribute("title")).toContain("Completed");
});
it("renders files-changed metadata and timer chip in footer row", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
@@ -514,6 +537,16 @@ describe("TaskCard", () => {
columnMovedAt: "2026-04-25T15:00:00.000Z",
updatedAt: "2026-04-25T15:00:00.000Z",
createdAt: "2026-04-25T13:00:00.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T13:00:00.000Z",
completedAt: "2026-04-25T15:00:00.000Z",
},
],
mergeDetails: {
commitSha: "abc123",
filesChanged: 4,
@@ -543,19 +576,24 @@ describe("TaskCard", () => {
expect(header?.contains(timer)).toBe(false);
expect(Array.from(footerRow?.children ?? [])).toEqual([filesChanged, timer]);
});
it.each(["triage", "todo", "in-review", "archived"] as const)(
"does not render timer chip for %s cards",
(column) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column,
columnMovedAt: "2026-04-25T15:00:00.000Z",
updatedAt: "2026-04-25T14:00:00.000Z",
createdAt: "2026-04-25T13:00:00.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T13:00:00.000Z",
completedAt: "2026-04-25T15:00:00.000Z",
},
],
})}
onOpenDetail={noop}
addToast={noop}
@@ -566,17 +604,14 @@ describe("TaskCard", () => {
},
);
it("suppresses timer chip when all timestamp fallbacks are invalid or missing", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
it("does not render timer chip when no instrumentation data is recorded", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
columnMovedAt: "not-a-date",
updatedAt: "also-not-a-date",
createdAt: undefined as unknown as string,
columnMovedAt: "2026-04-25T12:00:00.000Z",
updatedAt: "2026-04-25T12:00:00.000Z",
createdAt: "2026-04-25T11:58:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
@@ -586,77 +621,7 @@ describe("TaskCard", () => {
expect(container.querySelector(".card-time-indicator")).toBeNull();
});
it.each([
{
createdAt: "2026-04-25T09:00:00.000Z",
columnMovedAt: "2026-04-25T09:00:59.000Z",
expected: "<1m",
},
{
createdAt: "2026-04-25T09:00:00.000Z",
columnMovedAt: "2026-04-25T10:00:00.000Z",
expected: "1h",
},
{
createdAt: "2026-04-25T09:00:00.000Z",
columnMovedAt: "2026-04-26T09:00:00.000Z",
expected: "1d",
},
])(
"formats done processing-duration label as $expected at boundary",
({ createdAt, columnMovedAt, expected }) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-26T12:00:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
columnMovedAt,
updatedAt: columnMovedAt,
createdAt,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
expect(timer?.textContent).toContain(expected);
},
);
it("keeps done processing-duration timer stable when clock advances", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
columnMovedAt: "2026-04-25T15:00:00.000Z",
updatedAt: "2026-04-25T15:00:00.000Z",
createdAt: "2026-04-25T13:00:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("2h");
act(() => {
vi.advanceTimersByTime(2 * 60 * 60_000);
});
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("2h");
});
it("uses createdAt for done duration when updatedAt equals completion timestamp", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
it("does not render timer chip on done card without instrumentation, even with old timestamps", () => {
const { container } = render(
<TaskCard
task={makeTask({
@@ -670,14 +635,10 @@ describe("TaskCard", () => {
/>,
);
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
expect(timer?.textContent).toContain("2h");
expect(timer?.textContent).not.toContain("<1m");
expect(timer?.getAttribute("title")).toContain("Processing took 2h");
expect(container.querySelector(".card-time-indicator")).toBeNull();
});
it("refreshes in-progress timer chip on 30s cadence", () => {
it("live-ticks workflow runtime for in-progress steps", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T12:00:30.000Z"));
@@ -685,9 +646,15 @@ describe("TaskCard", () => {
<TaskCard
task={makeTask({
column: "in-progress",
columnMovedAt: "2026-04-25T12:00:00.000Z",
updatedAt: "2026-04-25T11:59:00.000Z",
createdAt: "2026-04-25T11:58:00.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "pending" as const,
startedAt: "2026-04-25T12:00:00.000Z",
},
],
})}
onOpenDetail={noop}
addToast={noop}

View File

@@ -116,7 +116,7 @@ describe("mobile CSS foundation", () => {
const css = loadAllAppCss();
const matches = [...css.matchAll(/@media\s*\(max-width:\s*(\d+)px\)/g)];
const foundValues = new Set(matches.map((match) => Number(match[1])));
const allowedValues = new Set([480, 640, 768, 860]);
const allowedValues = new Set([480, 640, 720, 768, 860]);
expect(foundValues.size).toBeGreaterThan(0);
for (const value of foundValues) {