[eric] workflows: preview header divider, HITL-gold schedule button, model/mode/secs subtitle, slicker calendar

This commit is contained in:
ciregenz
2026-05-25 11:16:30 -07:00
parent db4b944b0c
commit a137eba7c0
4 changed files with 120 additions and 106 deletions
@@ -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, any>): string {
const mcp = parseMcpToolName(toolName);
if (mcp.isMcp) {
@@ -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<Props> = ({
accent pill. */}
{isDraft && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
<SubtitleRow workflow={null} runs={null} />
<SubtitleRow
workflow={null}
runs={null}
fallbackModel={card?.draft?.model}
fallbackMode={card?.draft?.mode}
fallbackSourceSessionId={card?.draft?.source_session_id}
/>
<Box sx={{ flex: 1 }} />
<TabBtn label="History" icon={<HistoryIcon sx={{ fontSize: 16 }} />} active={false} onClick={() => {}} />
<TabBtn label="Run" icon={<PlayArrowIcon sx={{ fontSize: 16 }} />} active={false} accent onClick={() => {}} />
@@ -536,7 +543,7 @@ const WorkflowCard: React.FC<Props> = ({
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`. */}
<Box ref={bodyScrollRef} data-no-drag sx={{ flex: 1, p: 2, overflowY: 'auto', minHeight: 0, position: 'relative', overscrollBehavior: 'contain', display: 'flex', flexDirection: 'column' }}>
<Box ref={bodyScrollRef} data-no-drag sx={{ flex: 1, p: 2, overflowY: 'auto', minHeight: 0, position: 'relative', overscrollBehavior: 'contain', display: 'flex', flexDirection: 'column', borderTop: `1px solid ${c.border.subtle}` }}>
{/* 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 (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1.25, fontSize: '0.82rem', color: c.text.muted, minWidth: 0, overflow: 'hidden' }}>
{modelLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modelLabel}</Box>}
@@ -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,
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
<StepList steps={steps} expandable expandedIds={expandedIds} onToggleExpand={onToggleStep} />
<Box sx={{ flex: 1 }} />
{/* 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. */}
<Box sx={{
display: 'flex', alignItems: 'flex-start', gap: 1.25,
p: 1.5, borderRadius: `${c.radius.lg}px`,
bgcolor: c.accent.primary + '10',
border: `1px solid ${c.accent.primary}30`,
bgcolor: c.status.warning + '10',
border: `1px solid ${c.status.warning}30`,
}}>
<Box sx={{
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
bgcolor: c.accent.primary + '22', color: c.accent.primary,
bgcolor: c.status.warning + '22', color: c.status.warning,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<CalendarTodayRounded sx={{ fontSize: 16 }} />
<CalendarMonthRounded sx={{ fontSize: 18 }} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.95rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
@@ -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
</Box>
@@ -288,7 +289,7 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
cursor: scheduleClickable ? 'pointer' : 'default',
'&:hover': scheduleClickable ? { color: c.text.primary } : {},
}}>
<CalendarTodayRounded sx={{ fontSize: 15, color: c.text.muted, flexShrink: 0 }} />
<CalendarMonthRounded sx={{ fontSize: 16, color: c.text.muted, flexShrink: 0 }} />
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{scheduleLine}</Box>
</Box>
<Box
+67
View File
@@ -0,0 +1,67 @@
// Wall-clock "work time" for an agent session: how long the user actually
// waited across all turns (prompt -> 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`;
}