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:
132
src/App.jsx
132
src/App.jsx
@@ -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) */}
|
||||
|
||||
107
src/styles.css
107
src/styles.css
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user