@@ -287,6 +287,24 @@ function UserMessageContent({ content, files }) {
287287 )
288288}
289289
290+ function editableMessageText ( message ) {
291+ if ( typeof message . content === 'string' ) return message . content
292+ if ( ! Array . isArray ( message . content ) ) return null
293+ const textBlock = message . content . find ( block => block ?. type === 'text' )
294+ return typeof textBlock ?. text === 'string' ? textBlock . text : null
295+ }
296+
297+ function withEditedMessageText ( message , text ) {
298+ if ( typeof message . content === 'string' ) return { ...message , content : text }
299+ const textIndex = message . content . findIndex ( block => block ?. type === 'text' )
300+ return {
301+ ...message ,
302+ content : message . content . map ( ( block , index ) =>
303+ index === textIndex ? { ...block , text } : block
304+ ) ,
305+ }
306+ }
307+
290308export default function Chat ( ) {
291309 const { model : urlModel } = useParams ( )
292310 const { addToast } = useOutletContext ( )
@@ -329,6 +347,8 @@ export default function Chat() {
329347 const [ clientMCPServers , setClientMCPServers ] = useState ( ( ) => loadClientMCPServers ( ) )
330348 const [ confirmDialog , setConfirmDialog ] = useState ( null )
331349 const [ completionGlowIdx , setCompletionGlowIdx ] = useState ( - 1 )
350+ const [ editingMessageIndex , setEditingMessageIndex ] = useState ( null )
351+ const [ messageEditDraft , setMessageEditDraft ] = useState ( '' )
332352 const prevStreamingRef = useRef ( false )
333353 const {
334354 connect : mcpConnect , disconnect : mcpDisconnect , disconnectAll : mcpDisconnectAll ,
@@ -545,6 +565,33 @@ export default function Chat() {
545565 updateChatSettings ( activeChat . id , { clientMCPServers : next } )
546566 } , [ activeChat , updateChatSettings ] )
547567
568+ const startMessageEdit = useCallback ( ( index , message ) => {
569+ const text = editableMessageText ( message )
570+ if ( text === null ) return
571+ setEditingMessageIndex ( index )
572+ setMessageEditDraft ( text )
573+ } , [ ] )
574+
575+ const cancelMessageEdit = useCallback ( ( ) => {
576+ setEditingMessageIndex ( null )
577+ setMessageEditDraft ( '' )
578+ } , [ ] )
579+
580+ const saveMessageEdit = useCallback ( ( ) => {
581+ if ( ! activeChat || isStreaming || editingMessageIndex === null || ! messageEditDraft . trim ( ) ) return
582+ const message = activeChat . history [ editingMessageIndex ]
583+ if ( ! message || editableMessageText ( message ) === null ) return
584+ const history = activeChat . history . map ( ( item , index ) =>
585+ index === editingMessageIndex ? withEditedMessageText ( item , messageEditDraft ) : item
586+ )
587+ updateChatSettings ( activeChat . id , { history } )
588+ cancelMessageEdit ( )
589+ } , [ activeChat , isStreaming , editingMessageIndex , messageEditDraft , updateChatSettings , cancelMessageEdit ] )
590+
591+ useEffect ( ( ) => {
592+ cancelMessageEdit ( )
593+ } , [ activeChat ?. id , isStreaming , cancelMessageEdit ] )
594+
548595 // Load initial message from home page
549596 const homeDataProcessed = useRef ( false )
550597 useEffect ( ( ) => {
@@ -1170,40 +1217,80 @@ export default function Chat() {
11701217 { msg . role === 'assistant' && activeChat . model && (
11711218 < span className = "chat-message-model" > { activeChat . model } </ span >
11721219 ) }
1173- < div className = "chat-message-content" >
1174- { msg . role === 'user' ? (
1175- < UserMessageContent content = { msg . content } files = { msg . files } />
1176- ) : (
1177- < div dangerouslySetInnerHTML = { {
1178- __html : canvasMode
1179- ? renderMarkdownWithArtifacts ( typeof msg . content === 'string' ? msg . content : '' , i )
1180- : renderMarkdown ( typeof msg . content === 'string' ? msg . content : '' )
1181- } } />
1182- ) }
1183- </ div >
1220+ { editingMessageIndex === i ? (
1221+ < div className = "chat-message-edit" >
1222+ < textarea
1223+ autoFocus
1224+ className = "chat-message-edit-input"
1225+ value = { messageEditDraft }
1226+ onChange = { ( event ) => setMessageEditDraft ( event . target . value ) }
1227+ onKeyDown = { ( event ) => {
1228+ if ( event . key === 'Escape' ) cancelMessageEdit ( )
1229+ } }
1230+ aria-label = { t ( 'actions.editMessage' ) }
1231+ />
1232+ < div className = "chat-message-edit-actions" >
1233+ < button
1234+ type = "button"
1235+ className = "btn btn-primary btn-sm"
1236+ onClick = { saveMessageEdit }
1237+ disabled = { ! messageEditDraft . trim ( ) }
1238+ >
1239+ { t ( 'actions.save' ) }
1240+ </ button >
1241+ < button
1242+ type = "button"
1243+ className = "btn btn-secondary btn-sm"
1244+ onClick = { cancelMessageEdit }
1245+ >
1246+ { t ( 'actions.cancel' ) }
1247+ </ button >
1248+ </ div >
1249+ </ div >
1250+ ) : (
1251+ < div className = "chat-message-content" >
1252+ { msg . role === 'user' ? (
1253+ < UserMessageContent content = { msg . content } files = { msg . files } />
1254+ ) : (
1255+ < div dangerouslySetInnerHTML = { {
1256+ __html : canvasMode
1257+ ? renderMarkdownWithArtifacts ( typeof msg . content === 'string' ? msg . content : '' , i )
1258+ : renderMarkdown ( typeof msg . content === 'string' ? msg . content : '' )
1259+ } } />
1260+ ) }
1261+ </ div >
1262+ ) }
11841263 { msg . role === 'assistant' && typeof msg . content === 'string' && msg . content . includes ( 'Error:' ) && (
11851264 < a href = "/app/traces?tab=backend" className = "chat-error-trace-link" >
11861265 < i className = "fas fa-wave-square" /> { t ( 'errors.viewTraces' ) }
11871266 </ a >
11881267 ) }
1189- < div className = "chat-message-actions" >
1190- < button onClick = { ( ) => copyMessage ( msg . content ) } title = { t ( 'actions.copy' ) } >
1191- < i className = "fas fa-copy" />
1192- </ button >
1193- { msg . role === 'assistant' && ! isStreaming && (
1194- < button onClick = { ( ) => handleRegenerate ( i ) } title = { t ( 'actions.regenerate' ) } >
1195- < i className = "fas fa-rotate" />
1268+ { editingMessageIndex !== i && (
1269+ < div className = "chat-message-actions" >
1270+ < button onClick = { ( ) => copyMessage ( msg . content ) } title = { t ( 'actions.copy' ) } >
1271+ < i className = "fas fa-copy" />
11961272 </ button >
1197- ) }
1198- { msg . role === 'assistant' && ! isStreaming && (
1199- < button
1200- onClick = { ( ) => { forkChat ( activeChat . id , i + 1 ) ; addToast ( t ( 'toasts.forked' ) , 'success' , 2000 ) } }
1201- title = { t ( 'actions.branch' ) }
1202- >
1203- < i className = "fas fa-code-branch" />
1204- </ button >
1205- ) }
1206- </ div >
1273+ { ( msg . role === 'user' || msg . role === 'assistant' ) &&
1274+ editableMessageText ( msg ) !== null && ! isStreaming && (
1275+ < button onClick = { ( ) => startMessageEdit ( i , msg ) } title = { t ( 'actions.edit' ) } >
1276+ < i className = "fas fa-pen" />
1277+ </ button >
1278+ ) }
1279+ { msg . role === 'assistant' && ! isStreaming && (
1280+ < button onClick = { ( ) => handleRegenerate ( i ) } title = { t ( 'actions.regenerate' ) } >
1281+ < i className = "fas fa-rotate" />
1282+ </ button >
1283+ ) }
1284+ { msg . role === 'assistant' && ! isStreaming && (
1285+ < button
1286+ onClick = { ( ) => { forkChat ( activeChat . id , i + 1 ) ; addToast ( t ( 'toasts.forked' ) , 'success' , 2000 ) } }
1287+ title = { t ( 'actions.branch' ) }
1288+ >
1289+ < i className = "fas fa-code-branch" />
1290+ </ button >
1291+ ) }
1292+ </ div >
1293+ ) }
12071294 </ div >
12081295 </ div >
12091296 )
0 commit comments