[aidan] ux/workflows: polish workflow card interactions

This commit is contained in:
abccodes
2026-06-17 02:48:13 -07:00
parent f593214d9f
commit 570dc29b82
5 changed files with 114 additions and 24 deletions
@@ -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"