From aa905e54f072012c3fd0f36e67642ac5e9f4ebb5 Mon Sep 17 00:00:00 2001 From: abccodes Date: Tue, 16 Jun 2026 23:45:48 -0700 Subject: [PATCH] [aidan] ux/cards: click-to-rename for chat and workflow titles Single-click a card's title to enter edit mode inline. Commit on Enter/blur, cancel on Escape. Rename persists via PATCH for workflows and sessions. --- .../app/components/InlineEditableTitle.tsx | 87 ++++++++++++ .../app/pages/Dashboard/cards/AgentCard.tsx | 24 ++-- .../src/app/pages/Workflows/WorkflowCard.tsx | 130 +++++++++++++++++- frontend/src/shared/state/agentsSlice.ts | 15 ++ 4 files changed, 242 insertions(+), 14 deletions(-) create mode 100644 frontend/src/app/components/InlineEditableTitle.tsx diff --git a/frontend/src/app/components/InlineEditableTitle.tsx b/frontend/src/app/components/InlineEditableTitle.tsx new file mode 100644 index 00000000..a749d896 --- /dev/null +++ b/frontend/src/app/components/InlineEditableTitle.tsx @@ -0,0 +1,87 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import InputBase from '@mui/material/InputBase'; +import type { SxProps, Theme } from '@mui/material/styles'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +interface Props { + // Current title as shown to the user; seeds the edit field. + value: string; + // Called with the trimmed new title only when it actually changed. + onCommit: (next: string) => void; + // Layout + text styling shared by the read-only text and the input so the + // two states line up (pass flex/font/color here). + sx?: SxProps; + placeholder?: string; + // Optional custom display node (e.g. the chat card's Typewriter); falls + // back to a plain Typography of `value` when omitted. + children?: React.ReactNode; +} + +// Click-to-rename title. Reads as plain text until clicked, then becomes an +// inline input that commits on Enter/blur and cancels on Escape. Lives on +// pointer-drag card headers, so it stops pointer propagation (+ data-no-drag) +// to avoid starting a card drag while editing. +export default function InlineEditableTitle({ value, onCommit, sx, placeholder, children }: Props) { + const c = useClaudeTokens(); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); + const inputRef = useRef(null); + + useEffect(() => { + if (editing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [editing]); + + const begin = useCallback(() => { setDraft(value); setEditing(true); }, [value]); + + const commit = useCallback(() => { + const t = draft.trim(); + if (t && t !== value) onCommit(t); + setEditing(false); + }, [draft, value, onCommit]); + + 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 } }} + /> + ); + } + + return ( + e.stopPropagation()} + title="Click to rename" + sx={{ + minWidth: 0, cursor: 'text', borderRadius: 0.5, px: 0.25, mx: -0.25, + '&:hover': { bgcolor: c.bg.elevated }, + ...sx, + }} + > + {children ?? ( + + {value} + + )} + + ); +} diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index ef4e35ba..6cdc1521 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -19,9 +19,11 @@ import { handleApproval, collapseSession, closeSession, + renameSession, } from '@/shared/state/agentsSlice'; import { displayChatTitle, isLegacyAutoName } from '@/shared/state/sessionDisplay'; import { Typewriter } from '@/app/components/feedback/Animated'; +import InlineEditableTitle from '@/app/components/InlineEditableTitle'; import { setCardPosition, setCardSize, @@ -789,16 +791,22 @@ const AgentCard: React.FC = ({ borderRadius: 1, }} > - dispatch(renameSession({ sessionId: session.id, name }))} + sx={{ flex: 1, color: c.text.primary, fontWeight: 600, fontSize: '0.95rem' }} > - {(t) => ( - - {t} - - )} - + + {(t) => ( + + {t} + + )} + + {/* Status speaks only when it needs the user; finished work sits quiet. The welcome chat hides its 'draft' label so the title reads clean. */} {session.status !== 'completed' && session.status !== 'stopped' && !session.is_welcome_draft && ( diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index 7e40cdde..80d79f9d 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -9,11 +9,13 @@ import CloseIcon from '@mui/icons-material/Close'; import HistoryIcon from '@mui/icons-material/HistoryRounded'; import PlayArrowIcon from '@mui/icons-material/PlayArrowRounded'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined'; import InputBase from '@mui/material/InputBase'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { closeWorkflowCard, + deleteWorkflow, fetchRuns, openWorkflowCard as openWorkflowCardAction, rekeyOpenCard, @@ -38,11 +40,12 @@ import { HistoryDetail, HistoryList, PreviewView, SavedView } from './WorkflowCa import { CompletedView, FailedView, RunningView } from './WorkflowCardLiveViews'; import SchedulingView from './SchedulingView'; import EditAgentView from './EditAgentView'; +import InlineEditableTitle from '@/app/components/InlineEditableTitle'; 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'; +import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime'; type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; @@ -193,6 +196,25 @@ const WorkflowCard: React.FC = ({ const isDraft = card?.view === 'preview' && !workflow; const steps = (workflow?.steps || card?.draft?.steps || []) as Workflow['steps']; + // Edit-agent chrome lives in the card header: the model/time subtitle and + // the Save Workflow button. EditAgentView owns the live session and reports + // 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 [editSessionId, setEditSessionId] = useState(null); + const editSession = useAppSelector((s) => editSessionId ? s.agents.sessions[editSessionId] : undefined); + const [savePulseNonce, setSavePulseNonce] = useState(0); + const prevEditStatusRef = useRef(undefined); + useEffect(() => { + const status = editSession?.status; + const prev = prevEditStatusRef.current; + const wasRunning = prev === 'running' || prev === 'waiting_approval'; + if (wasRunning && status === 'completed' && steps.length > 0) { + setSavePulseNonce((n) => n + 1); + } + prevEditStatusRef.current = status; + }, [editSession?.status, steps.length]); + // ---- Card drag via title bar ---- const DRAG_THRESHOLD = 3; const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null); @@ -348,9 +370,15 @@ const WorkflowCard: React.FC = ({ // user can re-open from the Workflows hub. A confirm dialog here was // more friction than value (users clicked through it without reading). const onClose = useCallback(() => { + // A 0-step workflow can't run or be scheduled, so a "+ New" card the user + // opened and abandoned (without the build agent adding any steps) would + // just litter the hub. Delete it on close rather than orphan it. + if (workflow && (workflow.steps?.length ?? 0) === 0) { + dispatch(deleteWorkflow(workflow.id)); + } dispatch(closeWorkflowCard(workflowId)); dispatch(removeWorkflowCard(workflowId)); - }, [dispatch, workflowId]); + }, [dispatch, workflowId, workflow]); // ---- Display calculations ---- const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; @@ -472,14 +500,63 @@ const WorkflowCard: React.FC = ({ /> ) : ( <> - - {title} - + {workflow ? ( + dispatch(updateWorkflow({ id: workflow.id, patch: { title: name }, ifMatch: workflow.updated_at || null }))} + sx={{ flex: '0 1 auto', fontWeight: 600, fontSize: '0.95rem', color: c.text.primary, letterSpacing: '-0.005em' }} + /> + ) : ( + + {title} + + )} )} {runs && runs.length > 0 && } + {!isDraft && workflow && isEditAgentView && ( + 0 ? 'Save the workflow and close the editor' : 'Add at least one step before saving'}> + { + e.stopPropagation(); + if (steps.length === 0) { setRunToast('Add at least one step to your workflow first.'); return; } + dispatch(updateWorkflowCard({ workflowId, patch: { view: 'saved' } })); + }} + onPointerDown={(e) => e.stopPropagation()} + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.5, + fontSize: '0.78rem', fontWeight: 700, + px: 1.1, py: 0.5, + borderRadius: `${c.radius.md}px`, + cursor: 'pointer', + ...(steps.length > 0 ? { + color: '#fff', + bgcolor: c.accent.primary, + border: `1px solid ${c.accent.primary}`, + boxShadow: `0 0 0 0 ${c.accent.primary}00`, + animation: savePulseNonce > 0 ? `workflow-save-pulse-${savePulseNonce} 0.95s ease-out 1` : 'none', + [`@keyframes workflow-save-pulse-${savePulseNonce}`]: { + '0%': { boxShadow: `0 0 0 0 ${c.accent.primary}55`, transform: 'scale(1)' }, + '55%': { boxShadow: `0 0 0 8px ${c.accent.primary}00`, transform: 'scale(1.035)' }, + '100%': { boxShadow: `0 0 0 0 ${c.accent.primary}00`, transform: 'scale(1)' }, + }, + '&:hover': { filter: 'brightness(1.05)' }, + } : { + color: c.text.muted, + bgcolor: c.bg.elevated, + border: `1px solid ${c.border.subtle}`, + }), + }} + > + + Save Workflow + + + )} = ({ + {/* Edit-agent view borrows the chat card's subtitle: model + live work + time, so the workflow editor reads the same as a normal chat. */} + {!isDraft && workflow && isEditAgentView && ( + + + + )} + {/* Action bar matches new design: History + Run flush-right (Edit moved to footer). The flex spacer is the empty left side; History is a quiet text link, Run is the accent pill. */} @@ -645,7 +730,7 @@ const WorkflowCard: React.FC = ({ )} {(card.view === 'edit_agent' || card.view === 'fix_agent') && workflow && ( - + )} @@ -743,6 +828,39 @@ function StatusPill({ view, workflow, runs }: { view: string; workflow: Workflow ); } +// Mirrors the dashboard chat card's "Claude Sonnet 4.6 4s" subtitle, but for +// the live edit-agent session driving the workflow editor. Self-ticks at 1Hz +// while the agent is working so the time counts up like a normal chat. +function EditAgentSubtitle({ session }: { session: import('@/shared/state/agentsSlice').AgentSession | undefined }) { + const c = useClaudeTokens(); + const modelsByProvider = useAppSelector((s) => s.models.byProvider); + const [, setTick] = useState(0); + const status = session?.status; + useEffect(() => { + if (status !== 'running' && status !== 'waiting_approval') return; + const id = setInterval(() => setTick((t) => (t + 1) & 0xffff), 1000); + return () => clearInterval(id); + }, [status]); + const modelLabel = React.useMemo(() => { + const value = session?.model; + if (!value) return ''; + for (const list of Object.values(modelsByProvider || {})) { + for (const m of (list as any[]) || []) { + if (m.value === value) return m.label || value; + } + } + return value; + }, [session?.model, modelsByProvider]); + if (!session) return null; + const lastSec = getAgentWorkTime(session.messages || [], session.status || '').last; + return ( + + {modelLabel && {modelLabel}} + {lastSec > 0 && {fmtSeconds(lastSec)}} + + ); +} + function SubtitleRow({ workflow, runs, fallbackModel, fallbackMode, fallbackSourceSessionId }: { workflow: Workflow | null; runs: import('@/shared/state/workflowsSlice').WorkflowRun[] | null; diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index d64f52df..c4af374c 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -386,6 +386,21 @@ export const updateSystemPrompt = createAsyncThunk( } ); +export const renameSession = createAsyncThunk( + 'agents/rename', + async ({ sessionId, name }: { sessionId: string; name: string }, { dispatch }) => { + // Optimistic local update; the backend echoes the new name back over + // the agent:status broadcast, which keeps every open card in sync. + dispatch(updateSessionName({ sessionId, name })); + await fetch(`${AGENTS_API}/sessions/${sessionId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + return { sessionId, name }; + } +); + export const updateThinkingLevel = createAsyncThunk( 'agents/updateThinkingLevel', async ({ sessionId, level }: { sessionId: string; level: 'off' | 'low' | 'medium' | 'high' | 'auto' }) => {