mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 10:17:43 +02:00
[eric] workflows: preview header divider, HITL-gold schedule button, model/mode/secs subtitle, slicker calendar
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user