/**
* Quick Panel Message Renderer
*
* Renders AgentChat-compatible messages for the Quick Panel AI Chat UI.
* Features:
* - Markdown rendering for assistant messages via markstream-vue
* - XSS-safe rendering for user messages (textContent only)
* - Streaming message support (in-place updates via message id)
* - Auto-scroll with proximity detection
* - Memory-efficient DOM recycling
*/
import type { AgentMessage, AgentRole } from 'chrome-mcp-shared';
import { createMarkdownRenderer, type MarkdownRendererInstance } from './markdown-renderer';
// ============================================================
// Types
// ============================================================
export interface QuickPanelMessageRendererOptions {
/** Container element for message nodes (typically `.qp-messages`) */
container: HTMLElement;
/** Scroll container for auto-scroll heuristics (typically `.qp-content`) */
scrollContainer?: HTMLElement | null;
/** Auto-scroll on new/updated messages when user is near bottom. Default: true */
autoScroll?: boolean;
/** Pixel threshold for "near bottom" detection. Default: 96 */
autoScrollThresholdPx?: number;
}
export interface QuickPanelMessageRenderer {
/** Insert or update a message by id */
upsert: (message: AgentMessage) => void;
/** Remove a message by id */
remove: (messageId: string) => void;
/** Clear all messages */
clear: () => void;
/** Replace all messages with a new array */
setMessages: (messages: AgentMessage[]) => void;
/** Get current message count */
getMessageCount: () => number;
/** Force scroll to bottom */
scrollToBottom: () => void;
/** Clean up resources */
dispose: () => void;
}
// ============================================================
// Internal Types
// ============================================================
/** DOM elements for a single message entry */
interface MessageEntry {
wrapper: HTMLDivElement;
bubble: HTMLDivElement;
textEl: HTMLDivElement;
metaEl: HTMLDivElement;
metaLeftEl: HTMLDivElement;
streamDotEl: HTMLSpanElement;
timeEl: HTMLSpanElement;
metaRightEl: HTMLSpanElement;
requestIdEl: HTMLElement;
/** Markdown renderer for assistant messages */
markdownRenderer: MarkdownRendererInstance | null;
}
// ============================================================
// Constants
// ============================================================
const DEFAULT_AUTO_SCROLL_THRESHOLD_PX = 96;
/** Maximum length for truncated request ID display */
const REQUEST_ID_DISPLAY_LENGTH = 10;
// ============================================================
// Utility Functions
// ============================================================
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function joinClasses(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ');
}
function formatMessageTime(isoString: string): string {
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) return '';
try {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch {
return '';
}
}
function isStreamingMessage(message: AgentMessage): boolean {
return message.isStreaming === true && message.isFinal !== true;
}
function getWrapperClassName(role: AgentRole): string {
return role === 'user' ? 'qp-msg qp-msg--user' : 'qp-msg qp-msg--assistant';
}
function getBubbleClassName(role: AgentRole): string {
return joinClasses('qp-bubble', role === 'user' && 'qp-bubble--user');
}
function formatRequestIdForDisplay(requestId: string): { short: string; full: string } {
const full = requestId.trim();
const short =
full.length <= REQUEST_ID_DISPLAY_LENGTH ? full : full.slice(0, REQUEST_ID_DISPLAY_LENGTH);
return { short, full };
}
/**
* Get a label prefix for special message types
*/
function getMessageTypeLabel(message: AgentMessage): string | null {
if (message.role === 'tool') return 'Tool';
if (message.role === 'system') return 'System';
if (message.messageType === 'tool_use') return 'Tool';
if (message.messageType === 'tool_result') return 'Result';
return null;
}
// ============================================================
// DOM Creation Helpers
// ============================================================
function createMetaLeftElement(): {
container: HTMLDivElement;
streamDot: HTMLSpanElement;
time: HTMLSpanElement;
} {
const container = document.createElement('div');
Object.assign(container.style, {
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
minWidth: '0',
});
const streamDot = document.createElement('span');
streamDot.className = 'qp-msg-stream-dot ac-pulse';
streamDot.hidden = true;
const time = document.createElement('span');
container.append(streamDot, time);
return { container, streamDot, time };
}
function createMetaRightElement(): { container: HTMLSpanElement; requestId: HTMLElement } {
const container = document.createElement('span');
container.hidden = true;
const requestId = document.createElement('code');
container.append(requestId);
return { container, requestId };
}
function createMessageEntry(messageId: string, message: AgentMessage): MessageEntry {
const wrapper = document.createElement('div');
wrapper.className = getWrapperClassName(message.role);
wrapper.dataset.messageId = messageId;
wrapper.dataset.role = message.role;
wrapper.dataset.messageType = message.messageType;
const bubble = document.createElement('div');
bubble.className = getBubbleClassName(message.role);
const textEl = document.createElement('div');
textEl.className = 'qp-msg-text';
const metaEl = document.createElement('div');
metaEl.className = 'qp-msg-meta';
const metaLeft = createMetaLeftElement();
const metaRight = createMetaRightElement();
metaEl.append(metaLeft.container, metaRight.container);
bubble.append(textEl, metaEl);
wrapper.append(bubble);
// Create markdown renderer for assistant messages
let markdownRenderer: MarkdownRendererInstance | null = null;
if (message.role === 'assistant') {
markdownRenderer = createMarkdownRenderer(textEl);
}
return {
wrapper,
bubble,
textEl,
metaEl,
metaLeftEl: metaLeft.container,
streamDotEl: metaLeft.streamDot,
timeEl: metaLeft.time,
metaRightEl: metaRight.container,
requestIdEl: metaRight.requestId,
markdownRenderer,
};
}
// ============================================================
// Entry Update Logic
// ============================================================
function updateMessageEntry(entry: MessageEntry, messageId: string, message: AgentMessage): void {
// Update wrapper classes and data attributes
const wrapperClass = getWrapperClassName(message.role);
if (entry.wrapper.className !== wrapperClass) {
entry.wrapper.className = wrapperClass;
}
entry.wrapper.dataset.role = message.role;
entry.wrapper.dataset.messageType = message.messageType;
entry.wrapper.dataset.messageId = messageId;
// Update bubble class
const bubbleClass = getBubbleClassName(message.role);
if (entry.bubble.className !== bubbleClass) {
entry.bubble.className = bubbleClass;
}
// Update content based on message role
const textContent = message.content ?? '';
if (message.role === 'assistant' && entry.markdownRenderer) {
// Use markdown renderer for assistant messages
entry.markdownRenderer.setContent(textContent, isStreamingMessage(message));
} else {
// Use plain text for user messages (XSS-safe)
if (entry.textEl.textContent !== textContent) {
entry.textEl.textContent = textContent;
}
}
// Update time display
const typeLabel = getMessageTypeLabel(message);
const timeText = formatMessageTime(message.createdAt) || '\u2014'; // em dash for empty
entry.timeEl.textContent = typeLabel ? `${typeLabel} \u2022 ${timeText}` : timeText;
// Update streaming indicator
entry.streamDotEl.hidden = !isStreamingMessage(message);
// Update request ID display
const rawRequestId = isNonEmptyString(message.requestId) ? message.requestId.trim() : '';
if (rawRequestId) {
const formatted = formatRequestIdForDisplay(rawRequestId);
entry.requestIdEl.textContent = formatted.short;
entry.requestIdEl.title = formatted.full;
entry.metaRightEl.hidden = false;
} else {
entry.requestIdEl.textContent = '';
entry.requestIdEl.title = '';
entry.metaRightEl.hidden = true;
}
}
// ============================================================
// Main Factory
// ============================================================
/**
* Create a message renderer instance for the Quick Panel AI Chat.
*
* @example
* ```typescript
* const renderer = createQuickPanelMessageRenderer({
* container: messagesEl,
* scrollContainer: contentEl,
* });
*
* // Render streaming message
* renderer.upsert(message);
*
* // Clean up
* renderer.dispose();
* ```
*/
export function createQuickPanelMessageRenderer(
options: QuickPanelMessageRendererOptions,
): QuickPanelMessageRenderer {
const container = options.container;
const scrollContainer = options.scrollContainer ?? null;
const autoScroll = options.autoScroll ?? true;
const thresholdPx = options.autoScrollThresholdPx ?? DEFAULT_AUTO_SCROLL_THRESHOLD_PX;
/** Map of messageId -> DOM entry */
const entries = new Map<string, MessageEntry>();
let disposed = false;
// --------------------------------------------------------
// Scroll Management
// --------------------------------------------------------
function isNearBottom(): boolean {
if (!scrollContainer) return true;
const { scrollHeight, scrollTop, clientHeight } = scrollContainer;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
return distanceFromBottom <= thresholdPx;
}
function scrollToBottom(): void {
if (!scrollContainer) return;
try {
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight });
} catch {
// Fallback for older browsers
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}
// --------------------------------------------------------
// Core Operations
// --------------------------------------------------------
function upsert(message: AgentMessage): void {
if (disposed) return;
const messageId = message.id?.trim();
if (!messageId) return;
const shouldAutoScroll = autoScroll && isNearBottom();
let entry = entries.get(messageId);
if (!entry) {
entry = createMessageEntry(messageId, message);
entries.set(messageId, entry);
container.append(entry.wrapper);
}
updateMessageEntry(entry, messageId, message);
if (shouldAutoScroll) {
scrollToBottom();
}
}
function remove(messageId: string): void {
if (disposed) return;
const id = messageId?.trim();
if (!id) return;
const entry = entries.get(id);
if (!entry) return;
entries.delete(id);
// Dispose markdown renderer if exists
if (entry.markdownRenderer) {
entry.markdownRenderer.dispose();
}
try {
entry.wrapper.remove();
} catch {
// Fallback for edge cases
entry.wrapper.parentElement?.removeChild(entry.wrapper);
}
}
function clear(): void {
if (disposed) return;
// Dispose all markdown renderers
for (const entry of entries.values()) {
if (entry.markdownRenderer) {
entry.markdownRenderer.dispose();
}
}
entries.clear();
container.textContent = '';
}
function setMessages(messages: AgentMessage[]): void {
if (disposed) return;
// Dispose all existing markdown renderers
for (const entry of entries.values()) {
if (entry.markdownRenderer) {
entry.markdownRenderer.dispose();
}
}
// Clear existing state
entries.clear();
container.textContent = '';
// Render all messages
for (const msg of messages) {
const id = msg.id?.trim();
if (!id) continue;
const entry = createMessageEntry(id, msg);
entries.set(id, entry);
updateMessageEntry(entry, id, msg);
container.append(entry.wrapper);
}
// Scroll to bottom after batch render
scrollToBottom();
}
function getMessageCount(): number {
return entries.size;
}
function dispose(): void {
if (disposed) return;
disposed = true;
// Dispose all markdown renderers
for (const entry of entries.values()) {
if (entry.markdownRenderer) {
entry.markdownRenderer.dispose();
}
}
entries.clear();
container.textContent = '';
}
// --------------------------------------------------------
// Public API
// --------------------------------------------------------
return {
upsert,
remove,
clear,
setMessages,
getMessageCount,
scrollToBottom,
dispose,
};
}