diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index fbacb217..0527ebe8 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -37,6 +37,7 @@ import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canv import { openWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice'; import { addWorkflowCard, setWorkflowCardPosition, setWorkflowCardSize } from '@/shared/state/dashboardLayoutSlice'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesomeOutlined'; +import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime'; /** Extract up to 3 substantive user-prompt steps to seed a workflow. */ function extractStepsFromSession(session: { messages: Array<{ role: string; content: unknown; hidden?: boolean }> }): Array<{ id: string; text: string }> { @@ -94,14 +95,6 @@ const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ servi return null; }; -function fmtSeconds(seconds: number): string { - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ${seconds % 60}s`; - const hours = Math.floor(minutes / 60); - return `${hours}h ${minutes % 60}m`; -} - /** Self-ticking elapsed-time leaf; owns its 1Hz interval so AgentCard doesn't re-render every second. */ const ElapsedTimer: React.FC<{ messages: Array<{ role: string; timestamp: string; elapsed_ms?: number; hidden?: boolean }>; @@ -116,74 +109,6 @@ const ElapsedTimer: React.FC<{ return <>{fmtSeconds(getAgentWorkTime(messages, status).last)}; }); -function getAgentWorkTime( - messages: Array<{ role: string; timestamp: string; elapsed_ms?: number; hidden?: boolean }>, - status: string, -): { total: number; last: number } { - // True wall-clock duration: how long the user actually waited, from - // their prompt to the LAST assistant/system message of that turn. - // Covers thinking + every tool call + assistant text generation + - // any subagent/MCP work , anything that consumed user attention. - // - // This is intentionally NOT the sum of `thinking.elapsed_ms` (which - // would cover only reasoning time and miss tool execution). The - // thinking pill in the chat already exposes reasoning-only as a - // distinct signal; the header timer's job is to answer "how long - // did this take?" which is a different question. - // - // For each user message we find the LAST adjacent assistant/system - // message before the next user message , that's the turn boundary. - // If the turn is still in flight (last user message has no assistant - // reply yet AND session is running/waiting), extrapolate to now so - // the timer ticks live. - // - // Hidden messages (auto-continuation prompts from MCPActivate, etc.) - // are skipped , they're system-internal turns the user didn't see - // and shouldn't be billed for. - const visible = messages.filter((m) => !m.hidden); - let totalMs = 0; - let lastMs = 0; - for (let i = 0; i < visible.length; i++) { - const msg = visible[i]; - if (msg.role !== 'user') continue; - - let nextUserIdx = visible.length; - for (let k = i + 1; k < visible.length; k++) { - if (visible[k].role === 'user') { - nextUserIdx = k; - break; - } - } - - let turnEndMs: number | null = null; - for (let k = nextUserIdx - 1; k > i; k--) { - const r = visible[k].role; - if (r === 'assistant' || r === 'system') { - turnEndMs = new Date(visible[k].timestamp).getTime(); - break; - } - } - - if (turnEndMs == null) { - // No reply yet; extrapolate to now while running so the header ticks. Terminal sessions contribute 0. - if (status === 'running' || status === 'waiting_approval') { - turnEndMs = Date.now(); - } else { - continue; - } - } - - const dur = Math.max(0, turnEndMs - new Date(msg.timestamp).getTime()); - totalMs += dur; - lastMs = dur; - } - - return { - total: Math.max(0, Math.round(totalMs / 1000)), - last: Math.max(0, Math.round(lastMs / 1000)), - }; -} - function summarizeToolInput(toolName: string, toolInput: Record): string { const mcp = parseMcpToolName(toolName); if (mcp.isMcp) { diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index aa2d5732..fff81fe9 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -42,6 +42,7 @@ import StopRounded from '@mui/icons-material/StopRounded'; import PauseRounded from '@mui/icons-material/PauseRounded'; import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun } from './workflowVisuals'; import { store } from '@/shared/state/store'; +import { getAgentWorkTime } from '@/shared/agentWorkTime'; type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; @@ -486,7 +487,13 @@ const WorkflowCard: React.FC = ({ accent pill. */} {isDraft && ( - + } active={false} onClick={() => {}} /> } active={false} accent onClick={() => {}} /> @@ -536,7 +543,7 @@ const WorkflowCard: React.FC = ({ Crossfades between Run/Edit/History tabs so the swap doesn't read as a "jump". Outer box is the scrollable viewport; the animated child changes per `card.view`. */} - + {/* No AnimatePresence wrapper here on purpose: framer-motion's crossfade was racing user-input events and stealing focus from the title/description/step InputBases on every parent @@ -727,34 +734,48 @@ function StatusPill({ view, workflow, runs }: { view: string; workflow: Workflow ); } -function SubtitleRow({ workflow, runs }: { workflow: Workflow | null; runs: import('@/shared/state/workflowsSlice').WorkflowRun[] | null }) { +function SubtitleRow({ workflow, runs, fallbackModel, fallbackMode, fallbackSourceSessionId }: { + workflow: Workflow | null; + runs: import('@/shared/state/workflowsSlice').WorkflowRun[] | null; + fallbackModel?: string; + fallbackMode?: string; + fallbackSourceSessionId?: string | null; +}) { const c = useClaudeTokens(); const modelsByProvider = useAppSelector((s) => s.models.byProvider); + // Draft preview has no workflow yet; fall back to the converting chat's + // model/mode and use its work time for the "28s" so the subtitle reads the + // same as the source chat card did. + const sourceSession = useAppSelector((s) => fallbackSourceSessionId ? s.agents.sessions[fallbackSourceSessionId] : undefined); // Match Image #34/#35/#36/#38/#40: "Claude Opus 4.6 agent 28s". - // Spaces between fields, all in muted text. Duration is the most - // recent finished run's elapsed time; falls back to nothing when no - // run has completed yet (PreviewView). + // Spaces between fields, all in muted text. + const effModel = workflow?.model || fallbackModel || ''; const modelLabel = React.useMemo(() => { - if (!workflow?.model) return ''; + if (!effModel) return ''; for (const list of Object.values(modelsByProvider || {})) { for (const m of (list as any[]) || []) { - if (m.value === workflow.model) return m.label || workflow.model; + if (m.value === effModel) return m.label || effModel; } } - return workflow.model; - }, [workflow?.model, modelsByProvider]); - const modeLabel = workflow?.mode || ''; + return effModel; + }, [effModel, modelsByProvider]); + const modeLabel = workflow?.mode || fallbackMode || ''; const duration = React.useMemo(() => { - if (!runs || runs.length === 0) return ''; - const last = runs.find((r) => r.finished_at); - if (!last || !last.finished_at) return ''; - const ms = new Date(last.finished_at).getTime() - new Date(last.started_at).getTime(); - if (ms <= 0) return ''; - if (ms < 1000) return `${ms}ms`; - if (ms < 60_000) return `${Math.round(ms / 1000)}s`; - const m = Math.floor(ms / 60_000); - return `${m}m`; - }, [runs]); + const finished = (runs || []).find((r) => r.finished_at); + if (finished && finished.finished_at) { + const ms = new Date(finished.finished_at).getTime() - new Date(finished.started_at).getTime(); + if (ms > 0) { + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${Math.round(ms / 1000)}s`; + return `${Math.floor(ms / 60_000)}m`; + } + } + if (sourceSession) { + const { total } = getAgentWorkTime(sourceSession.messages || [], sourceSession.status); + if (total > 0) return total < 60 ? `${total}s` : `${Math.floor(total / 60)}m`; + } + return ''; + }, [runs, sourceSession]); return ( {modelLabel && {modelLabel}} diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index 04024cbd..3f480431 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -5,7 +5,7 @@ import Popover from '@mui/material/Popover'; import Tooltip from '@mui/material/Tooltip'; import InputBase from '@mui/material/InputBase'; import HistoryIcon from '@mui/icons-material/HistoryToggleOffRounded'; -import CalendarTodayRounded from '@mui/icons-material/CalendarTodayRounded'; +import CalendarMonthRounded from '@mui/icons-material/CalendarMonthRounded'; import EditOutlined from '@mui/icons-material/EditOutlined'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; @@ -163,19 +163,20 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, - {/* Schedule prompt card. Soft accent tint + calendar icon, matching Image #7. */} + {/* Schedule prompt card. Soft warning-gold tint + calendar icon, matching + Image #35. Gold is the same token the HITL/human-intervention UI uses. */} - + @@ -204,10 +205,10 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, display: 'inline-flex', alignItems: 'center', gap: 0.5, fontSize: '0.88rem', fontWeight: 700, px: 1.75, py: 0.6, borderRadius: 999, - color: '#fff', bgcolor: c.accent.primary, + color: '#fff', bgcolor: c.status.warning, cursor: busy ? 'wait' : 'pointer', opacity: busy ? 0.6 : 1, - '&:hover': { bgcolor: c.accent.primary, filter: 'brightness(1.06)' }, + '&:hover': { bgcolor: c.status.warning, filter: 'brightness(1.06)' }, }}> Schedule Workflow @@ -288,7 +289,7 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo cursor: scheduleClickable ? 'pointer' : 'default', '&:hover': scheduleClickable ? { color: c.text.primary } : {}, }}> - + {scheduleLine} last assistant/system reply of that turn). +// Shared so the dashboard chat card timer and the workflow subtitle report the +// exact same number for the same session. + +type WorkMessage = { role: string; timestamp: string; elapsed_ms?: number; hidden?: boolean }; + +export function getAgentWorkTime( + messages: WorkMessage[], + status: string, +): { total: number; last: number } { + // Covers thinking + every tool call + assistant text generation + any + // subagent/MCP work, anything that consumed user attention. NOT the sum of + // thinking.elapsed_ms (that misses tool execution). For each user message we + // find the LAST adjacent assistant/system message before the next user + // message, that's the turn boundary. In-flight turns extrapolate to now while + // running. Hidden messages (auto-continuation prompts) are skipped. + const visible = messages.filter((m) => !m.hidden); + let totalMs = 0; + let lastMs = 0; + for (let i = 0; i < visible.length; i++) { + const msg = visible[i]; + if (msg.role !== 'user') continue; + + let nextUserIdx = visible.length; + for (let k = i + 1; k < visible.length; k++) { + if (visible[k].role === 'user') { + nextUserIdx = k; + break; + } + } + + let turnEndMs: number | null = null; + for (let k = nextUserIdx - 1; k > i; k--) { + const r = visible[k].role; + if (r === 'assistant' || r === 'system') { + turnEndMs = new Date(visible[k].timestamp).getTime(); + break; + } + } + + if (turnEndMs == null) { + if (status === 'running' || status === 'waiting_approval') { + turnEndMs = Date.now(); + } else { + continue; + } + } + + const dur = Math.max(0, turnEndMs - new Date(msg.timestamp).getTime()); + totalMs += dur; + lastMs = dur; + } + + return { + total: Math.max(0, Math.round(totalMs / 1000)), + last: Math.max(0, Math.round(lastMs / 1000)), + }; +} + +export function fmtSeconds(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ${seconds % 60}s`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +}