groupchat/client/src/components/message-bubble.riot
Jason Tudisco 7210acf032 fix: include room ID in message permalink for cross-room navigation
Links now use #roomId/messageHash format so the app can load the correct
room before scrolling to the target message. Handles hashchange events
and auto-navigates on page load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:31:12 -06:00

497 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<message-bubble>
<div class={'message ' + (props.message?.is_ai ? 'ai-message' : '') + (props.isOwn ? ' own-message' : '')}>
<div class="message-avatar-col">
<div class={'message-avatar ' + (props.message?.is_ai ? 'ai-avatar' : '')}>
<svg if={props.message?.is_ai} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="8" width="16" height="12" rx="2"/>
<line x1="12" y1="2" x2="12" y2="8"/>
<circle cx="12" cy="2" r="1" fill="currentColor"/>
<circle cx="9" cy="14" r="1.5" fill="currentColor"/>
<circle cx="15" cy="14" r="1.5" fill="currentColor"/>
</svg>
<img if={!props.message?.is_ai}
src={getMessageAvatar(props.message)}
alt={props.message?.sender_name}
width="32"
height="32"
class="avatar-img"
loading="lazy"
/>
</div>
</div>
<div class="message-body">
<div class={'message-header ' + (props.isOwn ? 'own' : '')}>
<span class="sender-name">{props.message?.sender_name}</span>
<span class="message-time">{formatTime(props.message?.created_at)}</span>
</div>
<div if={hasToolResults()} class="tool-results-section">
<div each={tr in getToolResults()} class="tool-result-item">
<button class="tool-result-toggle" onclick={toggleToolResult}>
<span class="tool-result-icon">{tr.tool === 'brave_search' ? '🔍' : tr.tool === 'web_fetch' ? '🌐' : '⚙️'}</span>
<span class="tool-result-label">{tr.tool === 'brave_search' ? 'Search' : tr.tool === 'web_fetch' ? 'Fetched' : tr.tool}: {tr.input}</span>
<span class="tool-result-arrow">▼</span>
</button>
<div class="tool-result-body collapsed">
<pre class="tool-result-content">{tr.result}</pre>
</div>
</div>
</div>
<div if={props.message?.image_url} class="message-image-wrap">
<img src={props.message.image_url} alt="Attached image" class="message-image" loading="lazy" onclick={openImageFullscreen} />
</div>
<div if={props.isStreaming} class="message-content streaming-content">{props.message?.content}<span class="streaming-cursor">▌</span></div>
<div if={!props.isStreaming} class="message-content markdown-content"></div>
<div if={!props.isStreaming} class="message-actions-bar">
<button class="msg-action-btn" onclick={copyFullMessage} title="Copy message">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<button if={props.message?.hash} class="msg-action-btn" onclick={copyMessageLink} title="Copy link to message">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</button>
<template if={props.message?.is_ai && props.message?.ai_meta}>
<span class="ai-stat-item model">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a4 4 0 0 0-4 4v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6a4 4 0 0 0-4-4z"/></svg>
{formatModel(props.message.ai_meta.model)}
</span>
<span class="ai-stat-item" title="Generation speed">
⚡ {calcSpeed(props.message.ai_meta)} tok/sec
</span>
<span class="ai-stat-item" title="Completion tokens">
🎯 {props.message.ai_meta.completion_tokens} tokens
</span>
<span class="ai-stat-item" title="Response time">
⏱ {(props.message.ai_meta.response_ms / 1000).toFixed(1)}s
</span>
</template>
</div>
</div>
</div>
<style>
.message {
display: flex;
gap: var(--space-sm);
max-width: 80%;
animation: fadeIn 0.2s ease;
}
.own-message {
margin-left: auto;
flex-direction: row-reverse;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.message-avatar-col {
flex-shrink: 0;
padding-top: 2px;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--text-xs);
color: var(--text-secondary);
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: var(--radius-full);
object-fit: cover;
}
.ai-avatar {
background: var(--accent);
color: white;
}
.message-body {
min-width: 0;
}
.message-header {
display: flex;
align-items: baseline;
gap: var(--space-sm);
margin-bottom: 2px;
}
.message-header.own {
justify-content: flex-end;
flex-direction: row-reverse;
}
.sender-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
}
.ai-message .sender-name {
color: var(--accent);
}
.message-time {
font-size: var(--text-xs);
color: var(--text-muted);
}
.message-content {
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
line-height: 1.6;
word-wrap: break-word;
}
.message:not(.own-message):not(.ai-message) .message-content {
background: var(--bg-tertiary);
border-top-left-radius: var(--radius-sm);
}
.own-message .message-content {
background: var(--accent);
color: white;
border-top-right-radius: var(--radius-sm);
}
.own-message .message-content :global(code) {
background: rgba(255, 255, 255, 0.15);
}
.own-message .message-content :global(a) {
color: white;
text-decoration: underline;
}
.ai-message .message-content {
background: var(--ai-bg);
border: 1px solid var(--ai-border);
border-top-left-radius: var(--radius-sm);
}
.tool-results-section {
margin-bottom: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.tool-result-item {
border-radius: var(--radius-md);
overflow: hidden;
}
.tool-result-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 5px 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: background var(--transition-fast);
text-align: left;
}
.tool-result-toggle:hover {
background: var(--bg-hover);
}
.tool-result-toggle.open {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.tool-result-icon {
flex-shrink: 0;
}
.tool-result-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.tool-result-arrow {
flex-shrink: 0;
font-size: 10px;
transition: transform 0.2s;
}
.tool-result-toggle.open .tool-result-arrow {
transform: rotate(180deg);
}
.tool-result-body {
border: 1px solid var(--border);
border-top: none;
border-bottom-left-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
background: var(--bg-primary);
}
.tool-result-body.collapsed {
display: none;
}
.tool-result-content {
margin: 0;
padding: 10px 12px;
font-size: 11px;
font-family: var(--font-mono, 'SF Mono', 'Fira Code', monospace);
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
.message-actions-bar {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: 4px;
padding: 4px var(--space-sm);
font-size: 11px;
color: var(--text-muted);
flex-wrap: wrap;
opacity: 0;
transition: opacity var(--transition-fast);
}
.message:hover .message-actions-bar,
.message-actions-bar:focus-within {
opacity: 1;
}
/* Always show for AI messages with stats */
.ai-message .message-actions-bar {
opacity: 1;
}
.msg-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
color: var(--text-muted);
padding: 3px;
border-radius: var(--radius-sm);
line-height: 1;
}
.msg-action-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.msg-action-btn.copied {
color: var(--success);
}
.ai-stat-item {
display: inline-flex;
align-items: center;
gap: 3px;
white-space: nowrap;
}
.ai-stat-item.model {
color: var(--accent);
font-weight: 500;
}
.message-image-wrap {
margin-bottom: 4px;
padding: 4px;
border-radius: var(--radius-md);
overflow: hidden;
}
.message-image {
max-width: 320px;
max-height: 280px;
border-radius: var(--radius-md);
cursor: pointer;
display: block;
object-fit: contain;
transition: opacity 0.15s;
}
.message-image:hover {
opacity: 0.9;
}
.streaming-content {
white-space: pre-wrap;
word-wrap: break-word;
}
.streaming-cursor {
animation: cursor-blink 0.8s step-end infinite;
color: var(--accent);
font-weight: 300;
}
@keyframes cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
</style>
<script>
import { renderMarkdown } from '../services/markdown.js'
import { avatarFromHash } from '../services/avatar.js'
export default {
/** Prefer custom avatar_url, fall back to Gravatar hash */
getMessageAvatar(msg) {
if (msg?.avatar_url) return msg.avatar_url
return avatarFromHash(msg?.avatar_hash, 32)
},
onMounted() {
this.renderContent()
},
onUpdated() {
this.renderContent()
},
renderContent() {
if (this.props.isStreaming) return // Don't markdown-render while streaming
const el = this.$('.message-content.markdown-content')
if (el && this.props.message?.content) {
el.innerHTML = renderMarkdown(this.props.message.content)
// Inject copy buttons into code blocks
el.querySelectorAll('pre').forEach((pre) => {
if (pre.querySelector('.code-copy-btn')) return
const btn = document.createElement('button')
btn.className = 'code-copy-btn'
btn.textContent = 'Copy'
btn.addEventListener('click', () => {
const code = pre.querySelector('code')
const text = code ? code.textContent : pre.textContent
navigator.clipboard.writeText(text).then(() => {
btn.textContent = 'Copied!'
btn.classList.add('copied')
setTimeout(() => {
btn.textContent = 'Copy'
btn.classList.remove('copied')
}, 2000)
})
})
pre.appendChild(btn)
})
}
},
formatTime(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
},
formatModel(model) {
if (!model) return 'unknown'
// "openai/gpt-4o" → "gpt-4o", "anthropic/claude-3.5-sonnet" → "claude-3.5-sonnet"
const parts = model.split('/')
return parts.length > 1 ? parts[parts.length - 1] : model
},
calcSpeed(meta) {
if (!meta || !meta.completion_tokens || !meta.response_ms) return ''
const seconds = meta.response_ms / 1000
if (seconds === 0) return ''
return (meta.completion_tokens / seconds).toFixed(1)
},
hasToolResults() {
const tr = this.props.message?.ai_meta?.tool_results
return tr && tr.length > 0
},
getToolResults() {
return this.props.message?.ai_meta?.tool_results || []
},
toggleToolResult(e) {
const toggle = e.currentTarget
const body = toggle.nextElementSibling
const isOpen = toggle.classList.contains('open')
if (isOpen) {
toggle.classList.remove('open')
body.classList.add('collapsed')
} else {
toggle.classList.add('open')
body.classList.remove('collapsed')
}
},
openImageFullscreen(e) {
const url = e.target.src
window.open(url, '_blank')
},
copyFullMessage(e) {
e.preventDefault()
e.stopPropagation()
const content = this.props.message?.content
if (!content) return
const btn = e.currentTarget
navigator.clipboard.writeText(content).then(() => {
btn.classList.add('copied')
btn.title = 'Copied!'
setTimeout(() => {
btn.classList.remove('copied')
btn.title = 'Copy message'
}, 2000)
})
},
copyMessageLink(e) {
e.preventDefault()
e.stopPropagation()
const hash = this.props.message?.hash
if (!hash) return
const roomId = this.props.message?.room_id
if (!roomId) return
const url = `${window.location.origin}${window.location.pathname}#${roomId}/${hash}`
const btn = e.currentTarget
navigator.clipboard.writeText(url).then(() => {
btn.classList.add('copied')
btn.title = 'Link copied!'
setTimeout(() => {
btn.classList.remove('copied')
btn.title = 'Copy link to message'
}, 2000)
})
},
}
</script>
</message-bubble>