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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user