diff --git a/frontend/src/app/components/InlineEditableTitle.tsx b/frontend/src/app/components/InlineEditableTitle.tsx index a749d896..b11d4188 100644 --- a/frontend/src/app/components/InlineEditableTitle.tsx +++ b/frontend/src/app/components/InlineEditableTitle.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import InputBase from '@mui/material/InputBase'; @@ -28,6 +28,8 @@ export default function InlineEditableTitle({ value, onCommit, sx, placeholder, const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); const inputRef = useRef(null); + const measureRef = useRef(null); + const [inputWidth, setInputWidth] = useState(null); useEffect(() => { if (editing && inputRef.current) { @@ -36,6 +38,12 @@ export default function InlineEditableTitle({ value, onCommit, sx, placeholder, } }, [editing]); + useLayoutEffect(() => { + if (!editing) return; + const measured = measureRef.current?.getBoundingClientRect().width ?? 0; + setInputWidth(Math.ceil(measured) + 2); + }, [draft, editing, placeholder]); + const begin = useCallback(() => { setDraft(value); setEditing(true); }, [value]); const commit = useCallback(() => { @@ -46,20 +54,43 @@ export default function InlineEditableTitle({ value, onCommit, sx, placeholder, if (editing) { return ( - e.stopPropagation()} - onChange={(e) => setDraft(e.target.value)} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); commit(); } - else if (e.key === 'Escape') { e.preventDefault(); setEditing(false); } - }} - sx={{ ...sx, '& input::placeholder': { color: c.text.muted, opacity: 1 } }} - /> + <> + + {draft || placeholder || ' '} + + e.stopPropagation()} + onChange={(e) => setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); commit(); } + else if (e.key === 'Escape') { e.preventDefault(); setEditing(false); } + }} + sx={{ + ...sx, + width: inputWidth ? `${inputWidth}px` : 'auto', + minWidth: 0, + maxWidth: '100%', + '& input': { width: '100%', minWidth: 0, p: 0 }, + '& input::placeholder': { color: c.text.muted, opacity: 1 }, + }} + /> + ); } diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 3bba341f..7778c34a 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -13,6 +13,7 @@ import CancelIcon from '@mui/icons-material/Cancel'; import CloseIcon from '@mui/icons-material/Close'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import TerminalIcon from '@mui/icons-material/Terminal'; +import CalendarMonthRounded from '@mui/icons-material/CalendarMonthRounded'; import { motion } from 'framer-motion'; import { AgentSession, @@ -40,7 +41,7 @@ import { useDashboardActive } from '@/shared/hooks/useDashboardActive'; import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough'; import { useStreamingMessage } from '@/shared/state/streamingSlice'; import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState'; -import { createWorkflow, openWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice'; +import { createWorkflow, openWorkflowCard, setCardSidecar, type Workflow } from '@/shared/state/workflowsSlice'; import { addWorkflowCard, setWorkflowCardPosition, setWorkflowCardSize } from '@/shared/state/dashboardLayoutSlice'; import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined'; import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime'; @@ -266,6 +267,16 @@ const AgentCard: React.FC = ({ // which is confusing identity collapse. const workflowRunsMap = useAppSelector((s) => s.workflows.runs); const workflowItems = useAppSelector((s) => s.workflows.items); + const linkedWorkflowSidecarId = useAppSelector((s) => { + const entry = Object.values(s.workflows.openCards).find((card) => card.sidecarSessionId === session.id); + return entry?.workflowId ?? null; + }); + const sourceWorkflow = useMemo(() => { + for (const wf of Object.values(workflowItems || {})) { + if (wf.source_session_id === session.id) return wf; + } + return null; + }, [workflowItems, session.id]); const isWorkflowRunnerSession = useMemo(() => { // A Test Agent (spawned to validate a workflow draft) isn't a chat to // convert; it carries workflow_test_state. @@ -275,11 +286,22 @@ const AgentCard: React.FC = ({ if (r.session_id === session.id) return true; } } - for (const wf of Object.values(workflowItems || {})) { - if (wf.source_session_id === session.id) return true; - } - return false; - }, [workflowRunsMap, workflowItems, session.id, session.workflow_test_state]); + return Boolean(sourceWorkflow); + }, [workflowRunsMap, sourceWorkflow, session.id, session.workflow_test_state]); + const openSourceWorkflowScheduling = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (!sourceWorkflow) return; + dispatch(addWorkflowCard({ workflowId: sourceWorkflow.id, sourceSessionId: null, expandedSessionIds })); + dispatch(setWorkflowCardPosition({ workflowId: sourceWorkflow.id, x: cardX, y: cardY })); + dispatch(setWorkflowCardSize({ workflowId: sourceWorkflow.id, width: cardWidth, height: cardHeight })); + dispatch(removeCard(session.id)); + dispatch(openWorkflowCard({ workflowId: sourceWorkflow.id, sourceSessionId: null, view: 'saved', draft: null, showScheduleNudge: true })); + }, [cardHeight, cardWidth, cardX, cardY, dispatch, expandedSessionIds, session.id, sourceWorkflow]); + const showSourceWorkflowSchedule = + !!sourceWorkflow && + !sourceWorkflow.schedule?.enabled && + (session.status === 'completed' || session.status === 'stopped') && + session.messages.length >= 2; // Curated picker label with a tidy fallback for unknowns. const friendlyModelLabel = useMemo(() => { const value = session.model; @@ -522,6 +544,9 @@ const AgentCard: React.FC = ({ const handleRemove = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); + if (linkedWorkflowSidecarId) { + dispatch(setCardSidecar({ workflowId: linkedWorkflowSidecarId, sessionId: null, kind: null })); + } dispatch(collapseSession(session.id)); dispatch(removeCard(session.id)); if (glowEntry) { @@ -803,7 +828,7 @@ const AgentCard: React.FC = ({ dispatch(renameSession({ sessionId: session.id, name }))} - sx={{ flex: 1, color: c.text.primary, fontWeight: 600, fontSize: '0.95rem' }} + sx={{ flex: '0 1 auto', minWidth: 0, maxWidth: '100%', color: c.text.primary, fontWeight: 600, fontSize: '0.95rem' }} > = ({ onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }} > + {showSourceWorkflowSchedule && ( + + e.stopPropagation()} + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.5, + color: '#fff', + bgcolor: c.accent.primary, + border: `1px solid ${c.accent.primary}`, + fontSize: '0.78rem', fontWeight: 700, + px: 1.1, py: 0.5, + borderRadius: `${c.radius.md}px`, + cursor: 'pointer', + '&:hover': { filter: 'brightness(1.05)' }, + }} + > + + Schedule Workflow + + + )} {(session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && !isWorkflowRunnerSession && ( ; cards: Record; + workflowOpenCards: Record; layoutInitialized: boolean; autoRevealSubAgents: boolean; expandedSessionIds: string[]; @@ -26,6 +27,7 @@ export function useSubAgentLifecycle({ isActive, sessions, cards, + workflowOpenCards, layoutInitialized, autoRevealSubAgents, expandedSessionIds, @@ -43,6 +45,11 @@ export function useSubAgentLifecycle({ const subSessions = Object.values(sessions).filter( (s) => (s.mode === 'sub-agent' || s.mode === 'invoked-agent') && s.parent_session_id, ); + const workflowSidecarSessionIds = new Set( + Object.values(workflowOpenCards || {}) + .map((card) => card.sidecarSessionId || null) + .filter((id): id is string => Boolean(id)), + ); // 1) Auto-reveal newly spawned sub-agents (skip already-terminal ones on load) for (const sub of subSessions) { @@ -103,6 +110,7 @@ export function useSubAgentLifecycle({ // 2) Auto-collapse sub-agents when they complete const TERMINAL = new Set(['completed', 'error', 'stopped']); for (const sub of subSessions) { + if (workflowSidecarSessionIds.has(sub.id)) continue; const prev = prevSubStatusRef.current[sub.id]; if (prev !== sub.status && TERMINAL.has(sub.status) && cards[sub.id]) { dispatch(collapseSession(sub.id)); @@ -121,6 +129,7 @@ export function useSubAgentLifecycle({ if (prev !== parent.status && TERMINAL.has(parent.status)) { const children = subSessions.filter((s) => s.parent_session_id === pid); for (const child of children) { + if (workflowSidecarSessionIds.has(child.id)) continue; if (!cards[child.id]) continue; dispatch(collapseSession(child.id)); dispatch(removeCard(child.id)); @@ -136,5 +145,5 @@ export function useSubAgentLifecycle({ if (parent) newParentStatuses[pid] = parent.status; } prevParentStatusRef.current = newParentStatuses; - }, [isActive, sessions, cards, layoutInitialized, autoRevealSubAgents, dispatch]); + }, [isActive, sessions, cards, workflowOpenCards, layoutInitialized, autoRevealSubAgents, dispatch]); } diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index 2481bf11..e37e4233 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -150,6 +150,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { isActive, sessions, cards, + workflowOpenCards, layoutInitialized, autoRevealSubAgents, expandedSessionIds, diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index 80d79f9d..d099f0b2 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -201,6 +201,7 @@ const WorkflowCard: React.FC = ({ // its id up here. The Save button pulses once when a turn finishes adding a // step, nudging the user that there's something worth saving. const isEditAgentView = card?.view === 'edit_agent' || card?.view === 'fix_agent'; + const showHeaderSaveWorkflow = isEditAgentView && !(card?.view === 'edit_agent' && workflow?.source_session_id); const [editSessionId, setEditSessionId] = useState(null); const editSession = useAppSelector((s) => editSessionId ? s.agents.sessions[editSessionId] : undefined); const [savePulseNonce, setSavePulseNonce] = useState(0); @@ -516,7 +517,7 @@ const WorkflowCard: React.FC = ({ )} {runs && runs.length > 0 && } - {!isDraft && workflow && isEditAgentView && ( + {!isDraft && workflow && showHeaderSaveWorkflow && ( 0 ? 'Save the workflow and close the editor' : 'Add at least one step before saving'}>