mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[aidan] ux/workflows: polish workflow card interactions
This commit is contained in:
@@ -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<HTMLInputElement>(null);
|
||||
const measureRef = useRef<HTMLSpanElement>(null);
|
||||
const [inputWidth, setInputWidth] = useState<number | null>(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 (
|
||||
<InputBase
|
||||
inputRef={inputRef}
|
||||
data-no-drag
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
onPointerDown={(e) => 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 } }}
|
||||
/>
|
||||
<>
|
||||
<Box
|
||||
ref={measureRef}
|
||||
component="span"
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
visibility: 'hidden',
|
||||
whiteSpace: 'pre',
|
||||
pointerEvents: 'none',
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{draft || placeholder || ' '}
|
||||
</Box>
|
||||
<InputBase
|
||||
inputRef={inputRef}
|
||||
data-no-drag
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
onPointerDown={(e) => 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 },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Props> = ({
|
||||
// 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<Props> = ({
|
||||
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<HTMLDivElement>) => {
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
<InlineEditableTitle
|
||||
value={displayChatTitle(session)}
|
||||
onCommit={(name) => 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' }}
|
||||
>
|
||||
<Typewriter
|
||||
value={displayChatTitle(session)}
|
||||
@@ -859,6 +884,29 @@ const AgentCard: React.FC<Props> = ({
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }}
|
||||
>
|
||||
{showSourceWorkflowSchedule && (
|
||||
<Tooltip title="Schedule the workflow made from this chat">
|
||||
<Box
|
||||
role="button"
|
||||
onClick={openSourceWorkflowScheduling}
|
||||
onMouseDown={(e) => 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)' },
|
||||
}}
|
||||
>
|
||||
<CalendarMonthRounded sx={{ fontSize: 14 }} />
|
||||
Schedule Workflow
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && !isWorkflowRunnerSession && (
|
||||
<Tooltip title="Turn this chat into a reusable, schedulable workflow">
|
||||
<Box
|
||||
|
||||
@@ -17,6 +17,7 @@ interface UseSubAgentLifecycleArgs {
|
||||
isActive: boolean;
|
||||
sessions: Record<string, AgentSession>;
|
||||
cards: Record<string, CardPosition>;
|
||||
workflowOpenCards: Record<string, { sidecarSessionId?: string | null; sidecarKind?: string | null }>;
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
isActive,
|
||||
sessions,
|
||||
cards,
|
||||
workflowOpenCards,
|
||||
layoutInitialized,
|
||||
autoRevealSubAgents,
|
||||
expandedSessionIds,
|
||||
|
||||
@@ -201,6 +201,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
// 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<string | null>(null);
|
||||
const editSession = useAppSelector((s) => editSessionId ? s.agents.sessions[editSessionId] : undefined);
|
||||
const [savePulseNonce, setSavePulseNonce] = useState(0);
|
||||
@@ -516,7 +517,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
</>
|
||||
)}
|
||||
{runs && runs.length > 0 && <RunSparkline runs={runs} />}
|
||||
{!isDraft && workflow && isEditAgentView && (
|
||||
{!isDraft && workflow && showHeaderSaveWorkflow && (
|
||||
<Tooltip title={steps.length > 0 ? 'Save the workflow and close the editor' : 'Add at least one step before saving'}>
|
||||
<Box
|
||||
role="button"
|
||||
|
||||
Reference in New Issue
Block a user