mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 18:57:43 +02:00
[eric] chat: pill-aware in-place message editing (skills render as chips, elements stay attached, no raw markup)
This commit is contained in:
@@ -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<Props> = 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<HTMLDivElement | null>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
@@ -1020,21 +1019,7 @@ const MessageBubble: React.FC<Props> = 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<Props> = 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.
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, width: '100%' }}>
|
||||
<InputBase
|
||||
multiline
|
||||
fullWidth
|
||||
value={editText}
|
||||
onChange={(e) => 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 },
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleCancelEdit}
|
||||
sx={{ color: c.text.muted, fontSize: '0.75rem', textTransform: 'none', minWidth: 0, px: 1 }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleSaveEdit}
|
||||
disabled={!editText.trim() || editText.trim() === rawText}
|
||||
sx={{
|
||||
color: c.accent.primary,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
textTransform: 'none',
|
||||
minWidth: 0,
|
||||
px: 1,
|
||||
'&.Mui-disabled': { color: c.text.ghost },
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
// 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.
|
||||
<MessageEditSurface
|
||||
userMessage={displayText}
|
||||
elementSuffix={rawText.slice(displayText.length)}
|
||||
elementLabels={selectedElements.map((el) => el.label)}
|
||||
onSave={(full) => { if (full !== rawText && onSaveEdit) onSaveEdit(message.id, full); onCancelEdit?.(); }}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
) : (
|
||||
<Box>
|
||||
{message.images && message.images.length > 0 && (
|
||||
|
||||
@@ -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<string, AttachedSkill> {
|
||||
const byName: Record<string, AttachedSkill> = {};
|
||||
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<Props> = ({ userMessage, elementSuffix, elementLabels, onSave, onCancel }) => {
|
||||
const c = useClaudeTokens();
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const skillsRef = useRef<Record<string, AttachedSkill>>({});
|
||||
|
||||
// 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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, width: '100%' }}>
|
||||
{elementLabels.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{elementLabels.map((label, i) => (
|
||||
<Box key={i} sx={{ fontSize: '0.68rem', color: c.text.muted, bgcolor: 'rgba(255,255,255,0.06)', border: `1px solid ${c.border.subtle}`, borderRadius: '999px', px: 0.9, py: 0.15, maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onKeyDown={(e) => {
|
||||
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` },
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||
<Button size="small" onClick={onCancel} sx={{ color: c.text.muted, fontSize: '0.75rem', textTransform: 'none', minWidth: 0, px: 1 }}>Cancel</Button>
|
||||
<Button size="small" onClick={save} sx={{ color: c.accent.primary, fontWeight: 600, fontSize: '0.75rem', textTransform: 'none', minWidth: 0, px: 1 }}>Save</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageEditSurface;
|
||||
Reference in New Issue
Block a user