From c0370e3a51851b9856765cc08714dae4e338f06b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 22 Jul 2026 15:38:10 -0700 Subject: [PATCH] [eric] chat: pill-aware in-place message editing (skills render as chips, elements stay attached, no raw markup) --- .../pages/AgentChat/bubbles/MessageBubble.tsx | 80 ++----------- .../AgentChat/bubbles/MessageEditSurface.tsx | 110 ++++++++++++++++++ 2 files changed, 120 insertions(+), 70 deletions(-) create mode 100644 frontend/src/app/pages/AgentChat/bubbles/MessageEditSurface.tsx diff --git a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx index 9479575a..e36ea81e 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx @@ -4,7 +4,7 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import TextField from '@mui/material/TextField'; -import InputBase from '@mui/material/InputBase'; +import MessageEditSurface from './MessageEditSurface'; import Button from '@mui/material/Button'; import Chip from '@mui/material/Chip'; import Tooltip from '@mui/material/Tooltip'; @@ -861,7 +861,6 @@ interface Props { const MessageBubble: React.FC = React.memo(({ message, editing = false, onSaveEdit, onCancelEdit, isStreaming, dynamicTurnLabel, viewportHeight = 0, viewportWidth = 0, scrollRoot = null, revealRef }) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); - const [editText, setEditText] = useState(''); const [pickerOpen, setPickerOpen] = useState(false); const bubbleRootRef = React.useRef(null); const contentRef = React.useRef(null); @@ -1020,21 +1019,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o } }, [message.id, openswarmError?.kind, dispatch]); - React.useEffect(() => { - if (editing) setEditText(rawText); - }, [editing, rawText]); - const handleCancelEdit = () => { - setEditText(''); - onCancelEdit?.(); - }; - - const handleSaveEdit = () => { - const trimmed = editText.trim(); - if (trimmed && trimmed !== rawText && onSaveEdit) { - onSaveEdit(message.id, trimmed); - } - setEditText(''); onCancelEdit?.(); }; @@ -1092,60 +1077,15 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o > {isUser ? ( editing ? ( - // Claude's edit grammar: the message widens to the full column and becomes an editable - // field in place (subtle surface, no explainer banner), with two quiet controls under it. - - setEditText(e.target.value)} - autoFocus - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSaveEdit(); - } - if (e.key === 'Escape') handleCancelEdit(); - }} - sx={{ - color: c.text.primary, - fontSize: '0.875rem', - lineHeight: 1.55, - bgcolor: 'rgba(255,255,255,0.06)', - borderRadius: '10px', - px: 1.25, - py: 1, - '& textarea': { p: 0 }, - }} - /> - - - - - + // Claude's edit grammar, pill-aware: skills render as chips and selected-elements stay + // attached (read-only) instead of the message dumping raw {{skill:...}} / element markup. + el.label)} + onSave={(full) => { if (full !== rawText && onSaveEdit) onSaveEdit(message.id, full); onCancelEdit?.(); }} + onCancel={handleCancelEdit} + /> ) : ( {message.images && message.images.length > 0 && ( diff --git a/frontend/src/app/pages/AgentChat/bubbles/MessageEditSurface.tsx b/frontend/src/app/pages/AgentChat/bubbles/MessageEditSurface.tsx new file mode 100644 index 00000000..542206e9 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/bubbles/MessageEditSurface.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useRef } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { + deserializeToEditor, serializeEditorContent, type AttachedSkill, +} from '@/app/components/editor/richEditorUtils'; + +const SKILL_RE = /\{\{skill:([^}]+)\}\}/g; + +// Reconstruct the AttachedSkill map from a message's raw {{skill:X}} markers so the edit surface can +// render them as real chips (like the composer) instead of the ugly raw braces the old textarea showed. +function skillsFromText(text: string): Record { + const byName: Record = {}; + let i = 0; + for (const m of text.matchAll(SKILL_RE)) { + const name = m[1]; + if (!byName[name]) byName[name] = { id: `edit-skill-${i++}-${name}`, name, content: '' }; + } + return byName; +} + +interface Props { + // The editable prose (everything before the element-context block). Skill markers render as chips. + userMessage: string; + // The trailing "Selected UI Elements" block (or ''), preserved read-only and re-attached on save. + elementSuffix: string; + // Human labels for the preserved element context, shown as quiet read-only chips. + elementLabels: string[]; + onSave: (fullContent: string) => void; + onCancel: () => void; +} + +// Pill-aware, in-place edit for a user message: same contenteditable + skill-chip grammar as the main +// composer, so editing a message with skills/selected-elements no longer dumps raw {{skill:...}} and +// "---Selected UI Elements---" text into a bare textarea. Elements stay attached (read-only) across an edit. +const MessageEditSurface: React.FC = ({ userMessage, elementSuffix, elementLabels, onSave, onCancel }) => { + const c = useClaudeTokens(); + const editorRef = useRef(null); + const skillsRef = useRef>({}); + + // Populate the contenteditable ONCE (uncontrolled: React must not re-write innerHTML under the cursor). + useEffect(() => { + const el = editorRef.current; + if (!el) return; + const remove = (id: string): void => { delete skillsRef.current[id]; }; + skillsRef.current = deserializeToEditor(el, userMessage, skillsFromText(userMessage), remove, c.font.mono, c.status.error); + el.focus(); + // Cursor to the end so typing appends, matching how you'd resume the message. + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const save = (): void => { + const el = editorRef.current; + if (!el) return; + const edited = serializeEditorContent(el, skillsRef.current).trim(); + if (!edited) return; // never let an edit blank the message + onSave(edited + elementSuffix); + }; + + return ( + + {elementLabels.length > 0 && ( + + {elementLabels.map((label, i) => ( + + {label} + + ))} + + )} + { + e.stopPropagation(); + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); save(); } + if (e.key === 'Escape') { e.preventDefault(); onCancel(); } + }} + sx={{ + color: c.text.primary, + fontSize: '0.875rem', + lineHeight: 1.55, + bgcolor: 'rgba(255,255,255,0.06)', + borderRadius: '10px', + px: 1.25, + py: 1, + minHeight: '1.55em', + outline: 'none', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + '&:focus': { boxShadow: `0 0 0 1px ${c.accent.primary}55` }, + }} + /> + + + + + + ); +}; + +export default MessageEditSurface;