Add user editing UI improvements and bubble styling

Implemented an editing state that visually highlights the user message bubble with an accent border and shadow.
  • Updated the message render logic to handle an  CSS class and overlay a textarea that perfectly aligns with the bubble using a hidden shadow element.
  • Re‑worked the  and  styles to apply the accent border, box‑shadow, and transition effects.
  • Adjusted the textarea styling (, ) so it inherits the bubble’s look, respects whitespace, and automatically resizes without extra padding or borders.
  • Added a hidden  to keep the bubble width/height consistent while editing.
  • Included new  button styling for expandable user messages.
  • Minor cleanup of redundant padding, margin, and color definitions in the styles.
  • Updated the JSX to conditionally render the overlay textarea only during editing, ensuring a smooth UX.
  • Added appropriate ARIA attributes and cursor styles for clarity.
This commit is contained in:
2025-08-26 04:49:54 +02:00
parent 7358c924ad
commit f5d383ec94
2 changed files with 173 additions and 66 deletions

View File

@@ -146,6 +146,7 @@ export default function App() {
setEditingMessageIndex(index);
setEditText(content || '');
}
function cancelEditMessage() {
setEditingMessageIndex(null);
setEditText('');
@@ -153,11 +154,21 @@ export default function App() {
async function commitEditMessage(index) {
const original = (messages[index]?.content || '').trim();
const next = (editText || '').trim();
const nextRaw = editText ?? '';
const next = nextRaw.trim();
// NEW: If empty after trimming, cancel edit (revert to original)
if (next.length === 0) {
cancelEditMessage();
return;
}
// If nothing changed, cancel edit
if (next === original) {
cancelEditMessage();
return;
}
const sessionId = activeSessionId;
if (!sessionId) return;
@@ -166,7 +177,6 @@ export default function App() {
prev.map(s => {
if (s.session_id !== sessionId) return s;
const old = s.messages || [];
// keep up to index (inclusive) and update that item
const updated = old.slice(0, index + 1).map((m, j) =>
j === index ? { ...m, content: next } : m
);
@@ -174,11 +184,12 @@ export default function App() {
})
);
// Exit edit mode immediately
setEditingMessageIndex(null);
setEditText('');
// ⬇️ Scroll the chat frame to the bottom after the DOM updates
requestAnimationFrame(() => scrollToBottom('auto', sessionId)); // use 'smooth' if you prefer
requestAnimationFrame(() => scrollToBottom('auto', sessionId));
try {
const resp = await fetch(`${ollamaApiUrl}/sessions/${sessionId}/messages/${index}`, {
@@ -1140,54 +1151,73 @@ export default function App() {
</div>
<div key={activeSessionId} className="chat" ref={chatRef} onClick={handleChatFrameClick}>
{messages.map((m, i) => (
<div key={m.id || i} id={m.id} className={'msg ' + (m.role === 'user' ? 'user' : 'assistant')}>
{m.role === 'assistant' ? (
<div className="assistant-message-wrapper">
<AssistantMessageContent content={m.content} streamOutput={streamOutput} />
{!isSending && (
<div className="message-options-bar assistant-options">
<button className="icon-button" title="Copy message" onClick={() => handleCopyMessage(m)}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
<button className="icon-button" title="Regenerate response" onClick={() => regenerateFromIndex(i)}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.3"></path></svg>
</button>
</div>
)}
</div>
) : (
<div className="user-message-wrapper">
{editingMessageIndex === i ? (
<TextareaAutosize
className="edit-message-input"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onBlur={cancelEditMessage}
onKeyDown={(e) => {
if (e.key === 'Escape') { e.preventDefault(); cancelEditMessage(); }
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitEditMessage(i); }
}}
autoFocus
maxRows={13}
/>
) : (
<div className="msg-content msg-content--user">{m.content}</div>
)}
{!isSending && (
<div className="message-options-bar user-options">
<button className="icon-button" title="Edit message" onClick={() => startEditMessage(i, m.content)}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
</button>
<button className="icon-button" title="Copy message" onClick={() => handleCopyMessage(m)}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
</div>
)}
</div>
)}
</div>
))}
{messages.map((m, i) => {
const isEditingThis = m.role === 'user' && editingMessageIndex === i;
return (
<div
key={m.id || i}
id={m.id}
className={
'msg ' +
(m.role === 'user' ? 'user' : 'assistant') +
(isEditingThis ? ' editing' : '')
}
>
{m.role === 'assistant' ? (
<div className="assistant-message-wrapper">
<AssistantMessageContent content={m.content} streamOutput={streamOutput} />
{!isSending && (
<div className="message-options-bar assistant-options">
<button className="icon-button" title="Copy message" onClick={() => handleCopyMessage(m)}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
<button className="icon-button" title="Regenerate response" onClick={() => regenerateFromIndex(i)}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.3"></path></svg>
</button>
</div>
)}
</div>
) : (
<div className="user-message-wrapper">
{isEditingThis ? (
<div className="msg-content msg-content--user editing">
{/* Shadow defines bubble width/height based on current text */}
<div className="user-edit-shadow" aria-hidden="true">
{editText}
</div>
{/* Overlay textarea fills the bubble exactly */}
<TextareaAutosize
className="edit-message-input edit-overlay"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onBlur={cancelEditMessage}
onKeyDown={(e) => {
if (e.key === 'Escape') { e.preventDefault(); cancelEditMessage(); }
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitEditMessage(i); }
}}
autoFocus
minRows={1}
/>
</div>
) : (
<div className="msg-content msg-content--user">{m.content}</div>
)}
{!isSending && !isEditingThis && (
<div className="message-options-bar user-options">
<button className="icon-button" title="Edit message" onClick={() => startEditMessage(i, m.content)}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
</button>
<button className="icon-button" title="Copy message" onClick={() => handleCopyMessage(m)}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
</div>
)}
</div>
)}
</div>
);
})}
</div>
{/* New message tip (active chat only) */}

View File

@@ -320,18 +320,46 @@ textarea.input {
flex-grow: 1; /* Allow textarea to take up available space */
}
/* /Users/giers/Heimgeist/src/styles.css */
/* User message bubble */
.msg.user {
background: var(--user-msg-bg);
margin-left: auto; /* Right-align user messages */
margin-left: auto; /* Right-align user messages */
max-width: 80%;
border: 1px solid var(--border);
margin-right: 5px;
margin-bottom: 15px;
transition: border-color .15s ease, box-shadow .15s ease; /* NEW */
}
.msg.user.editing {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
/* Render user messages as plain text (no markdown), preserving line breaks and wrapping */
.msg.user .msg-content--user {
white-space: pre-wrap; /* keep newlines */
overflow-wrap: anywhere; /* wrap long tokens/URLs */
word-break: break-word; /* fallback */
position: relative;
}
/* Expand link inside the user bubble */
.msg.user .user-msg-expand {
margin-top: 8px;
padding: 0;
background: transparent;
border: none;
color: var(--accent);
font-weight: 700; /* bold as requested */
cursor: pointer;
text-align: left; /* align with text content */
}
.msg.user .user-msg-expand:hover {
opacity: .9;
text-decoration: underline;
}
.msg.assistant {
@@ -793,22 +821,71 @@ input:checked + .slider:before {
.msg-content { /* container for assistant/user rendered markdown */
}
.msg.user .msg-content--user.editing {
/* Bubble (.msg.user) already provides padding/border/max-width/rounded corners. */
/* Keep this wrapper neutral — no extra padding or borders here. */
}
/* Editing textarea styled like user bubble */
/* Editing textarea should be visually indistinguishable from the user bubble */
.edit-message-input {
background: var(--user-msg-bg);
border: 1px solid var(--border);
padding: 12px 14px;
border-radius: 12px;
margin-left: auto;
margin-right: 5px;
margin-bottom: 15px;
max-width: 80%;
width: 100%;
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Helvetica, Arial;
line-height: 1.5;
resize: none;
overflow-y: auto;
display: block;
width: 100%;
height: 100%; /* autosize will override with inline height; harmless */
background: transparent; /* let the bubble show through */
border: 0; /* bubble has the border */
padding: 0; /* bubble provides padding */
margin: 0;
color: inherit;
font: inherit;
line-height: inherit;
letter-spacing: inherit;
white-space: pre-wrap; /* keep newlines like the view mode */
overflow-wrap: anywhere; /* wrap long tokens/URLs */
word-break: break-word;
resize: none; /* autosize controls height */
overflow: hidden; /* smooth growth */
outline: none;
}
.edit-message-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
/* Shadow stays in flow to set the bubble's width & height, but remains invisible */
.user-edit-shadow {
visibility: hidden;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
/* Overlayed textarea fills exactly the content box of the bubble */
.edit-message-input.edit-overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: transparent; /* bubble provides bg */
border: 0; /* bubble provides border */
padding: 0; /* bubble provides padding */
margin: 0;
color: inherit;
font: inherit;
line-height: inherit;
letter-spacing: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
resize: none; /* autosize controls height if needed */
overflow: hidden;
outline: none;
box-shadow: none;
}