rspace-online/shared/components/rstack-module-comments.ts

830 lines
26 KiB
TypeScript

/**
* <rstack-module-comments> — Spatial comment pins on rApp module pages.
*
* Anchors Figma-style threaded comment markers to `data-collab-id` elements.
* Stores pins in a per-space Automerge doc: `{space}:module-comments:pins`.
*
* Attributes: `module-id`, `space` (both set by shell).
*
* Events emitted:
* - `module-comment-pins-changed` on window when doc changes
*
* Events consumed:
* - `comment-pin-activate` on window → enter placement mode
* - `module-comment-pin-focus` on window → scroll to + highlight a pin
*/
import { moduleCommentsSchema, moduleCommentsDocId } from '../module-comment-schemas';
import type { ModuleCommentPin, ModuleCommentsDoc } from '../module-comment-types';
import type { CommentPinMessage } from '../comment-pin-types';
import type { DocumentId } from '../local-first/document';
import { getModuleApiBase } from '../url-helpers';
interface SpaceMember {
userDID: string;
username: string;
displayName?: string;
}
type OfflineRuntime = {
isInitialized: boolean;
subscribe<T extends Record<string, any>>(docId: DocumentId, schema: any): Promise<any>;
unsubscribe(docId: DocumentId): void;
change<T>(docId: DocumentId, message: string, fn: (doc: T) => void): void;
get<T>(docId: DocumentId): any;
onChange<T>(docId: DocumentId, cb: (doc: any) => void): () => void;
};
export class RStackModuleComments extends HTMLElement {
#moduleId = '';
#space = '';
#docId: DocumentId | null = null;
#runtime: OfflineRuntime | null = null;
#unsubChange: (() => void) | null = null;
#overlay: HTMLDivElement | null = null;
#popover: HTMLDivElement | null = null;
#placementMode = false;
#activePinId: string | null = null;
#members: SpaceMember[] | null = null;
#mentionDropdown: HTMLDivElement | null = null;
// Observers
#resizeObs: ResizeObserver | null = null;
#mutationObs: MutationObserver | null = null;
#intersectionObs: IntersectionObserver | null = null;
#scrollHandler: (() => void) | null = null;
#repositionTimer: ReturnType<typeof setTimeout> | null = null;
#runtimePollInterval: ReturnType<typeof setInterval> | null = null;
connectedCallback() {
this.#moduleId = this.getAttribute('module-id') || '';
this.#space = this.getAttribute('space') || '';
if (!this.#moduleId || !this.#space || this.#moduleId === 'rspace') return;
this.#docId = moduleCommentsDocId(this.#space);
// Create overlay layer
this.#overlay = document.createElement('div');
this.#overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9998;';
document.body.appendChild(this.#overlay);
// Create popover container
this.#popover = document.createElement('div');
this.#popover.style.cssText = `
display:none;position:fixed;z-index:10001;width:340px;max-height:400px;
background:#1e1e2e;border:1px solid #444;border-radius:10px;
box-shadow:0 8px 30px rgba(0,0,0,0.4);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
color:#e0e0e0;font-size:13px;overflow:hidden;pointer-events:auto;
`;
document.body.appendChild(this.#popover);
// Try connecting to runtime
this.#tryConnect();
// Listen for events
window.addEventListener('comment-pin-activate', this.#onActivate);
window.addEventListener('module-comment-pin-focus', this.#onPinFocus as EventListener);
document.addEventListener('pointerdown', this.#onDocumentClick);
}
disconnectedCallback() {
if (this.#unsubChange) this.#unsubChange();
if (this.#runtimePollInterval) clearInterval(this.#runtimePollInterval);
if (this.#resizeObs) this.#resizeObs.disconnect();
if (this.#mutationObs) this.#mutationObs.disconnect();
if (this.#intersectionObs) this.#intersectionObs.disconnect();
if (this.#scrollHandler) window.removeEventListener('scroll', this.#scrollHandler, true);
if (this.#repositionTimer) clearTimeout(this.#repositionTimer);
if (this.#overlay) this.#overlay.remove();
if (this.#popover) this.#popover.remove();
window.removeEventListener('comment-pin-activate', this.#onActivate);
window.removeEventListener('module-comment-pin-focus', this.#onPinFocus as EventListener);
document.removeEventListener('pointerdown', this.#onDocumentClick);
this.#exitPlacementMode();
}
// ── Runtime Connection ──
#tryConnect() {
const runtime = (window as any).__rspaceOfflineRuntime as OfflineRuntime | undefined;
if (runtime?.isInitialized) {
this.#onRuntimeReady(runtime);
} else {
let polls = 0;
this.#runtimePollInterval = setInterval(() => {
const rt = (window as any).__rspaceOfflineRuntime as OfflineRuntime | undefined;
if (rt?.isInitialized) {
clearInterval(this.#runtimePollInterval!);
this.#runtimePollInterval = null;
this.#onRuntimeReady(rt);
} else if (++polls > 20) {
clearInterval(this.#runtimePollInterval!);
this.#runtimePollInterval = null;
}
}, 500);
}
}
async #onRuntimeReady(runtime: OfflineRuntime) {
this.#runtime = runtime;
if (!this.#docId) return;
await runtime.subscribe<ModuleCommentsDoc>(this.#docId, moduleCommentsSchema);
this.#unsubChange = runtime.onChange<ModuleCommentsDoc>(this.#docId, () => {
this.#renderPins();
window.dispatchEvent(new CustomEvent('module-comment-pins-changed'));
});
// Set up observers for repositioning
this.#setupObservers();
this.#renderPins();
}
// ── Observers ──
#setupObservers() {
// Debounced reposition
const debouncedReposition = () => {
if (this.#repositionTimer) clearTimeout(this.#repositionTimer);
this.#repositionTimer = setTimeout(() => this.#renderPins(), 100);
};
// Scroll (capture phase for inner scrollable elements)
this.#scrollHandler = debouncedReposition;
window.addEventListener('scroll', this.#scrollHandler, true);
// Resize
this.#resizeObs = new ResizeObserver(debouncedReposition);
this.#resizeObs.observe(document.body);
// DOM mutations (elements added/removed)
this.#mutationObs = new MutationObserver(debouncedReposition);
this.#mutationObs.observe(document.body, { childList: true, subtree: true });
}
// ── Pin Rendering ──
#getDoc(): ModuleCommentsDoc | undefined {
if (!this.#runtime || !this.#docId) return undefined;
return this.#runtime.get<ModuleCommentsDoc>(this.#docId);
}
#getPinsForModule(): [string, ModuleCommentPin][] {
const doc = this.#getDoc();
if (!doc?.pins) return [];
return Object.entries(doc.pins).filter(
([, pin]) => pin.anchor.moduleId === this.#moduleId
);
}
#findCollabEl(id: string): Element | null {
const sel = `[data-collab-id="${CSS.escape(id)}"]`;
const found = document.querySelector(sel);
if (found) return found;
// Walk into shadow roots (one level deep — rApp components)
for (const el of document.querySelectorAll('*')) {
if (el.shadowRoot) {
const inner = el.shadowRoot.querySelector(sel);
if (inner) return inner;
}
}
return null;
}
#renderPins() {
if (!this.#overlay) return;
const pins = this.#getPinsForModule();
// Clear existing markers
this.#overlay.innerHTML = '';
for (const [pinId, pin] of pins) {
const el = this.#findCollabEl(pin.anchor.elementId);
if (!el) continue;
const rect = el.getBoundingClientRect();
// Skip if off-screen
if (rect.bottom < 0 || rect.top > window.innerHeight ||
rect.right < 0 || rect.left > window.innerWidth) continue;
const marker = document.createElement('div');
marker.dataset.pinId = pinId;
const isActive = this.#activePinId === pinId;
const isResolved = pin.resolved;
const msgCount = pin.messages?.length || 0;
marker.style.cssText = `
position:fixed;
left:${rect.right - 12}px;
top:${rect.top - 8}px;
width:24px;height:24px;
border-radius:50% 50% 50% 0;
transform:rotate(-45deg);
cursor:pointer;
pointer-events:auto;
display:flex;align-items:center;justify-content:center;
font-size:10px;font-weight:700;color:white;
transition:all 0.15s ease;
box-shadow:0 2px 8px rgba(0,0,0,0.3);
${isResolved
? 'background:#64748b;opacity:0.6;'
: isActive
? 'background:#8b5cf6;transform:rotate(-45deg) scale(1.15);'
: 'background:#14b8a6;'}
`;
// Badge with message count
if (msgCount > 0) {
const badge = document.createElement('span');
badge.textContent = String(msgCount);
badge.style.cssText = 'transform:rotate(45deg);';
marker.appendChild(badge);
}
marker.addEventListener('click', (e) => {
e.stopPropagation();
this.#openPopover(pinId, rect.right + 8, rect.top);
});
this.#overlay.appendChild(marker);
}
}
// ── Placement Mode ──
#onActivate = () => {
// Only handle if we're on a module page (not canvas)
if (this.#moduleId === 'rspace' || !this.#runtime) return;
if (this.#placementMode) {
this.#exitPlacementMode();
} else {
this.#enterPlacementMode();
}
};
#enterPlacementMode() {
this.#placementMode = true;
document.body.style.cursor = 'crosshair';
// Highlight valid targets
this.#highlightCollabElements(true);
}
#exitPlacementMode() {
this.#placementMode = false;
document.body.style.cursor = '';
this.#highlightCollabElements(false);
}
#highlightCollabElements(show: boolean) {
const els = document.querySelectorAll('[data-collab-id]');
els.forEach((el) => {
(el as HTMLElement).style.outline = show ? '2px dashed rgba(20,184,166,0.4)' : '';
(el as HTMLElement).style.outlineOffset = show ? '2px' : '';
});
// Also check shadow roots
for (const el of document.querySelectorAll('*')) {
if (el.shadowRoot) {
el.shadowRoot.querySelectorAll('[data-collab-id]').forEach((inner) => {
(inner as HTMLElement).style.outline = show ? '2px dashed rgba(20,184,166,0.4)' : '';
(inner as HTMLElement).style.outlineOffset = show ? '2px' : '';
});
}
}
}
#onDocumentClick = (e: PointerEvent) => {
if (!this.#placementMode) {
// Close popover on outside click
if (this.#popover?.style.display !== 'none' &&
!this.#popover?.contains(e.target as Node) &&
!(e.target as HTMLElement)?.closest?.('[data-pin-id]')) {
this.#closePopover();
}
return;
}
const target = (e.target as HTMLElement).closest('[data-collab-id]');
if (!target) return; // Ignore clicks not on collab elements
e.preventDefault();
e.stopPropagation();
const elementId = target.getAttribute('data-collab-id')!;
this.#exitPlacementMode();
this.#createPin(elementId);
};
// ── CRDT Operations ──
#getUserInfo(): { did: string; name: string } {
try {
const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}');
return {
did: sess.did || 'anon',
name: sess.username || sess.displayName || 'Anonymous',
};
} catch {
return { did: 'anon', name: 'Anonymous' };
}
}
#createPin(elementId: string) {
if (!this.#runtime || !this.#docId) return;
const user = this.#getUserInfo();
const pinId = crypto.randomUUID();
this.#runtime.change<ModuleCommentsDoc>(this.#docId, 'Add module comment pin', (doc) => {
doc.pins[pinId] = {
id: pinId,
anchor: { type: 'element', elementId, moduleId: this.#moduleId },
resolved: false,
messages: [],
createdAt: Date.now(),
createdBy: user.did,
createdByName: user.name,
};
});
// Open popover immediately for the new pin
requestAnimationFrame(() => {
const el = this.#findCollabEl(elementId);
if (el) {
const rect = el.getBoundingClientRect();
this.#openPopover(pinId, rect.right + 8, rect.top, true);
}
});
}
#addMessage(pinId: string, text: string) {
if (!this.#runtime || !this.#docId) return;
const user = this.#getUserInfo();
const msgId = crypto.randomUUID();
// Extract @mentions
const mentionedDids = this.#extractMentions(text);
this.#runtime.change<ModuleCommentsDoc>(this.#docId, 'Add comment', (doc) => {
const pin = doc.pins[pinId];
if (!pin) return;
pin.messages.push({
id: msgId,
authorId: user.did,
authorName: user.name,
text,
mentionedDids: mentionedDids.length > 0 ? mentionedDids : undefined,
createdAt: Date.now(),
});
});
// Notify space members
this.#notifySpaceMembers(pinId, text, user, mentionedDids);
// Notify @mentioned users
if (mentionedDids.length > 0) {
this.#notifyMentions(pinId, user, mentionedDids);
}
}
#resolvePin(pinId: string) {
if (!this.#runtime || !this.#docId) return;
this.#runtime.change<ModuleCommentsDoc>(this.#docId, 'Resolve pin', (doc) => {
const pin = doc.pins[pinId];
if (pin) pin.resolved = true;
});
this.#closePopover();
}
#unresolvePin(pinId: string) {
if (!this.#runtime || !this.#docId) return;
this.#runtime.change<ModuleCommentsDoc>(this.#docId, 'Unresolve pin', (doc) => {
const pin = doc.pins[pinId];
if (pin) pin.resolved = false;
});
}
#deletePin(pinId: string) {
if (!this.#runtime || !this.#docId) return;
this.#runtime.change<ModuleCommentsDoc>(this.#docId, 'Delete pin', (doc) => {
delete doc.pins[pinId];
});
this.#closePopover();
}
// ── Notifications ──
async #notifySpaceMembers(pinId: string, text: string, user: { did: string; name: string }, mentionedDids: string[]) {
const doc = this.#getDoc();
if (!doc?.pins[pinId]) return;
const pin = doc.pins[pinId];
const isReply = pin.messages.length > 1;
const pinIndex = Object.values(doc.pins)
.sort((a, b) => a.createdAt - b.createdAt)
.findIndex((p) => p.id === pinId) + 1;
try {
const base = getModuleApiBase('rspace');
await fetch(`${base}/api/comment-pins/notify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pinId, authorDid: user.did, authorName: user.name,
text, pinIndex, isReply, mentionedDids,
moduleId: this.#moduleId,
}),
});
} catch { /* fire and forget */ }
}
async #notifyMentions(pinId: string, user: { did: string; name: string }, mentionedDids: string[]) {
const doc = this.#getDoc();
if (!doc?.pins[pinId]) return;
const pinIndex = Object.values(doc.pins)
.sort((a, b) => a.createdAt - b.createdAt)
.findIndex((p) => p.id === pinId) + 1;
try {
const base = getModuleApiBase('rspace');
await fetch(`${base}/api/comment-pins/notify-mention`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pinId, authorDid: user.did, authorName: user.name,
mentionedDids, pinIndex, moduleId: this.#moduleId,
}),
});
} catch { /* fire and forget */ }
}
#extractMentions(text: string): string[] {
if (!this.#members) return [];
const matches = text.match(/@(\w+)/g);
if (!matches) return [];
const dids: string[] = [];
for (const match of matches) {
const username = match.slice(1).toLowerCase();
const member = this.#members.find(
(m) => m.username.toLowerCase() === username
);
if (member) dids.push(member.userDID);
}
return dids;
}
// ── Popover ──
#openPopover(pinId: string, x: number, y: number, focusInput = false) {
if (!this.#popover) return;
this.#activePinId = pinId;
const doc = this.#getDoc();
const pin = doc?.pins[pinId];
if (!pin) return;
const sortedPins = Object.values(doc!.pins).sort((a, b) => a.createdAt - b.createdAt);
const pinIndex = sortedPins.findIndex((p) => p.id === pinId) + 1;
let html = `<style>${POPOVER_STYLES}</style>`;
// Header
html += `
<div class="mcp-header">
<span class="mcp-pin-num">Pin #${pinIndex}</span>
<div class="mcp-actions">
${pin.resolved
? `<button class="mcp-btn" data-action="unresolve" title="Reopen">&#x21A9;</button>`
: `<button class="mcp-btn" data-action="resolve" title="Resolve">&#x2713;</button>`}
<button class="mcp-btn mcp-btn-danger" data-action="delete" title="Delete">&#x2715;</button>
</div>
</div>
`;
// Element anchor label
html += `<div class="mcp-anchor-label">${this.#esc(pin.anchor.elementId)}</div>`;
// Messages
if (pin.messages.length > 0) {
html += `<div class="mcp-messages">`;
for (const msg of pin.messages) {
const time = new Date(msg.createdAt).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
});
html += `
<div class="mcp-msg">
<div class="mcp-msg-top">
<span class="mcp-msg-author">${this.#esc(msg.authorName)}</span>
<span class="mcp-msg-time">${time}</span>
</div>
<div class="mcp-msg-text">${this.#formatText(msg.text)}</div>
</div>
`;
}
html += `</div>`;
}
// Input row
html += `
<div class="mcp-input-row">
<input type="text" class="mcp-input" placeholder="Add a comment... (@ to mention)" />
<button class="mcp-send">Send</button>
</div>
`;
this.#popover.innerHTML = html;
this.#popover.style.display = 'block';
// Position — keep within viewport
const popW = 340, popH = 400;
const clampedX = Math.min(x, window.innerWidth - popW - 8);
const clampedY = Math.min(Math.max(y, 8), window.innerHeight - popH - 8);
this.#popover.style.left = `${clampedX}px`;
this.#popover.style.top = `${clampedY}px`;
// Wire actions
this.#popover.querySelectorAll('.mcp-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
const action = (e.currentTarget as HTMLElement).dataset.action;
if (action === 'resolve') this.#resolvePin(pinId);
else if (action === 'unresolve') this.#unresolvePin(pinId);
else if (action === 'delete') this.#deletePin(pinId);
});
});
// Wire input
const input = this.#popover.querySelector('.mcp-input') as HTMLInputElement;
const sendBtn = this.#popover.querySelector('.mcp-send') as HTMLButtonElement;
const submitComment = () => {
const text = input.value.trim();
if (!text) return;
this.#addMessage(pinId, text);
input.value = '';
// Re-render popover to show new message
requestAnimationFrame(() => {
const el = this.#findCollabEl(pin.anchor.elementId);
if (el) {
const rect = el.getBoundingClientRect();
this.#openPopover(pinId, rect.right + 8, rect.top);
}
});
};
sendBtn.addEventListener('click', submitComment);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submitComment();
}
if (e.key === 'Escape') this.#closePopover();
});
// @mention autocomplete
input.addEventListener('input', () => this.#handleMentionInput(input));
// Prevent popover clicks from closing it
this.#popover.addEventListener('pointerdown', (e) => e.stopPropagation());
if (focusInput) {
requestAnimationFrame(() => input.focus());
}
this.#renderPins(); // Update active state on markers
}
#closePopover() {
if (this.#popover) this.#popover.style.display = 'none';
this.#activePinId = null;
this.#closeMentionDropdown();
this.#renderPins();
}
// ── Pin Focus (from bell) ──
#onPinFocus = (e: CustomEvent) => {
const { pinId, elementId } = e.detail || {};
if (!pinId) return;
const doc = this.#getDoc();
const pin = doc?.pins[pinId];
if (!pin || pin.anchor.moduleId !== this.#moduleId) return;
const el = this.#findCollabEl(pin.anchor.elementId || elementId);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
requestAnimationFrame(() => {
const rect = el.getBoundingClientRect();
this.#openPopover(pinId, rect.right + 8, rect.top);
});
}
};
// ── @Mention Autocomplete ──
async #fetchMembers() {
if (this.#members) return this.#members;
try {
const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}');
const res = await fetch(`${getModuleApiBase('rspace')}/api/space-members`, {
headers: sess?.accessToken ? { Authorization: `Bearer ${sess.accessToken}` } : {},
});
if (!res.ok) return [];
const data = await res.json();
this.#members = data.members || [];
return this.#members!;
} catch {
return [];
}
}
async #handleMentionInput(input: HTMLInputElement) {
const val = input.value;
const cursorPos = input.selectionStart || 0;
const before = val.slice(0, cursorPos);
const atMatch = before.match(/@(\w*)$/);
if (!atMatch) {
this.#closeMentionDropdown();
return;
}
const query = atMatch[1].toLowerCase();
const members = await this.#fetchMembers();
const filtered = members.filter(
(m) =>
m.username.toLowerCase().includes(query) ||
(m.displayName && m.displayName.toLowerCase().includes(query)),
);
if (filtered.length === 0) {
this.#closeMentionDropdown();
return;
}
this.#showMentionDropdown(filtered, input, atMatch.index!);
}
#showMentionDropdown(members: SpaceMember[], input: HTMLInputElement, atIndex: number) {
this.#closeMentionDropdown();
const dropdown = document.createElement('div');
dropdown.style.cssText = `
position:absolute;bottom:100%;left:12px;right:12px;
background:#2a2a3a;border:1px solid #555;border-radius:6px;
max-height:150px;overflow-y:auto;z-index:10002;
`;
for (const m of members.slice(0, 8)) {
const item = document.createElement('div');
item.style.cssText = 'padding:6px 10px;cursor:pointer;font-size:12px;display:flex;justify-content:space-between;';
item.innerHTML = `
<span style="font-weight:600;">${this.#esc(m.displayName || m.username)}</span>
<span style="color:#888;">@${this.#esc(m.username)}</span>
`;
item.addEventListener('mousedown', (e) => {
e.preventDefault();
const val = input.value;
const before = val.slice(0, atIndex);
const after = val.slice(input.selectionStart || atIndex);
input.value = `${before}@${m.username} ${after}`;
input.focus();
const newPos = atIndex + m.username.length + 2;
input.setSelectionRange(newPos, newPos);
this.#closeMentionDropdown();
});
item.addEventListener('mouseenter', () => { item.style.background = '#3a3a4a'; });
item.addEventListener('mouseleave', () => { item.style.background = ''; });
dropdown.appendChild(item);
}
const inputRow = this.#popover?.querySelector('.mcp-input-row');
if (inputRow) {
(inputRow as HTMLElement).style.position = 'relative';
inputRow.appendChild(dropdown);
}
this.#mentionDropdown = dropdown;
}
#closeMentionDropdown() {
if (this.#mentionDropdown) {
this.#mentionDropdown.remove();
this.#mentionDropdown = null;
}
}
// ── Helpers ──
#esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
#formatText(text: string): string {
// Escape first, then render @mentions with highlight
let escaped = this.#esc(text);
escaped = escaped.replace(/@(\w+)/g, '<span style="color:#14b8a6;font-weight:600;">@$1</span>');
return escaped;
}
static define(tag = 'rstack-module-comments') {
if (!customElements.get(tag)) customElements.define(tag, RStackModuleComments);
}
}
// ── Popover Styles ──
const POPOVER_STYLES = `
.mcp-header {
padding: 10px 12px;
border-bottom: 1px solid #333;
display: flex;
align-items: center;
justify-content: space-between;
}
.mcp-pin-num {
font-weight: 700;
color: #14b8a6;
}
.mcp-actions {
display: flex;
gap: 4px;
}
.mcp-btn {
background: none;
border: 1px solid #444;
border-radius: 4px;
padding: 2px 6px;
cursor: pointer;
color: #ccc;
font-size: 13px;
}
.mcp-btn:hover { background: #333; }
.mcp-btn-danger { color: #ef4444; }
.mcp-btn-danger:hover { background: #3a1a1a; }
.mcp-anchor-label {
padding: 4px 12px;
font-size: 11px;
color: #64748b;
border-bottom: 1px solid #333;
background: rgba(255,255,255,0.02);
}
.mcp-messages {
max-height: 200px;
overflow-y: auto;
padding: 8px 12px;
}
.mcp-msg {
margin-bottom: 10px;
}
.mcp-msg-top {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.mcp-msg-author {
font-weight: 600;
color: #c4b5fd;
font-size: 12px;
}
.mcp-msg-time {
color: #666;
font-size: 10px;
}
.mcp-msg-text {
margin-top: 3px;
line-height: 1.4;
word-break: break-word;
}
.mcp-input-row {
padding: 8px 12px;
border-top: 1px solid #333;
display: flex;
gap: 6px;
}
.mcp-input {
flex: 1;
background: #2a2a3a;
border: 1px solid #444;
border-radius: 6px;
padding: 6px 10px;
color: #e0e0e0;
font-size: 13px;
outline: none;
}
.mcp-input:focus {
border-color: #14b8a6;
}
.mcp-send {
background: #14b8a6;
color: white;
border: none;
border-radius: 6px;
padding: 6px 12px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.mcp-send:hover {
background: #0d9488;
}
`;