mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[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.
This commit is contained in:
@@ -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<Theme>;
|
||||
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<HTMLInputElement>(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 (
|
||||
<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 } }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={begin}
|
||||
// Card headers drag via onPointerDown + preventDefault, which otherwise
|
||||
// swallows this click. Stop the pointer here so the click reaches us and
|
||||
// enters edit mode (same trick the draft-title InputBase uses).
|
||||
onPointerDown={(e) => 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 ?? (
|
||||
<Typography sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', ...sx }}>
|
||||
{value}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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<Props> = ({
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typewriter
|
||||
<InlineEditableTitle
|
||||
value={displayChatTitle(session)}
|
||||
enabled={!!session.name && !isLegacyAutoName(session.name)}
|
||||
onCommit={(name) => dispatch(renameSession({ sessionId: session.id, name }))}
|
||||
sx={{ flex: 1, color: c.text.primary, fontWeight: 600, fontSize: '0.95rem' }}
|
||||
>
|
||||
{(t) => (
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t}
|
||||
</Typography>
|
||||
)}
|
||||
</Typewriter>
|
||||
<Typewriter
|
||||
value={displayChatTitle(session)}
|
||||
enabled={!!session.name && !isLegacyAutoName(session.name)}
|
||||
>
|
||||
{(t) => (
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t}
|
||||
</Typography>
|
||||
)}
|
||||
</Typewriter>
|
||||
</InlineEditableTitle>
|
||||
{/* 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 && (
|
||||
|
||||
@@ -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<Props> = ({
|
||||
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<string | null>(null);
|
||||
const editSession = useAppSelector((s) => editSessionId ? s.agents.sessions[editSessionId] : undefined);
|
||||
const [savePulseNonce, setSavePulseNonce] = useState(0);
|
||||
const prevEditStatusRef = useRef<string | undefined>(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<Props> = ({
|
||||
// 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<Props> = ({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '0.95rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: '-0.005em' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{workflow ? (
|
||||
<InlineEditableTitle
|
||||
value={title}
|
||||
onCommit={(name) => 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' }}
|
||||
/>
|
||||
) : (
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '0.95rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: '-0.005em' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
<StatusPill view={card.view} workflow={workflow} runs={runs} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
</>
|
||||
)}
|
||||
{runs && runs.length > 0 && <RunSparkline runs={runs} />}
|
||||
{!isDraft && workflow && isEditAgentView && (
|
||||
<Tooltip title={steps.length > 0 ? 'Save the workflow and close the editor' : 'Add at least one step before saving'}>
|
||||
<Box
|
||||
role="button"
|
||||
data-no-drag
|
||||
onClick={(e) => {
|
||||
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}`,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeOutlinedIcon sx={{ fontSize: 14 }} />
|
||||
Save Workflow
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
data-no-drag
|
||||
@@ -491,6 +568,14 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* 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 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
|
||||
<EditAgentSubtitle session={editSession} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 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<Props> = ({
|
||||
<SchedulingView workflow={workflow} steps={steps} />
|
||||
)}
|
||||
{(card.view === 'edit_agent' || card.view === 'fix_agent') && workflow && (
|
||||
<EditAgentView workflow={workflow} steps={steps} isFixMode={card.view === 'fix_agent'} />
|
||||
<EditAgentView workflow={workflow} steps={steps} isFixMode={card.view === 'fix_agent'} onEditSessionIdChange={setEditSessionId} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -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 (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1.5, fontSize: '0.82rem', color: c.text.muted, minWidth: 0, overflow: 'hidden' }}>
|
||||
{modelLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modelLabel}</Box>}
|
||||
{lastSec > 0 && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{fmtSeconds(lastSec)}</Box>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SubtitleRow({ workflow, runs, fallbackModel, fallbackMode, fallbackSourceSessionId }: {
|
||||
workflow: Workflow | null;
|
||||
runs: import('@/shared/state/workflowsSlice').WorkflowRun[] | null;
|
||||
|
||||
@@ -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' }) => {
|
||||
|
||||
Reference in New Issue
Block a user