/**
* Quick Panel AI Chat Panel
*
* A complete AI chat interface for Quick Panel, featuring:
* - Streaming message display with real-time updates
* - Liquid Glass design with AgentChat token compatibility
* - Full keyboard navigation (Enter to send, Esc to close)
* - Request lifecycle management (send, cancel, cleanup)
* - Auto-context collection (page URL, text selection)
*
* This component is framework-agnostic and renders directly to Shadow DOM
* for optimal isolation and performance in content script context.
*/
import type {
AgentMessage,
AgentStatusEvent,
AgentUsageStats,
RealtimeEvent,
} from 'chrome-mcp-shared';
import type { QuickPanelAIContext, QuickPanelSendToAIPayload } from '@/common/message-types';
import type { QuickPanelAgentBridge } from '../core/agent-bridge';
import { Disposer } from '@/entrypoints/web-editor-v2/utils/disposables';
import {
createQuickPanelMessageRenderer,
type QuickPanelMessageRenderer,
} from './message-renderer';
// ============================================================
// Types
// ============================================================
export interface QuickPanelAiChatPanelOptions {
/** Shadow DOM mount point (typically `elements.root` from shadow-host.ts) */
mount: HTMLElement;
/** Agent bridge for background communication */
agentBridge: QuickPanelAgentBridge;
/** Header title. Default: "Agent" */
title?: string;
/** Header subtitle. Default: "Quick Panel" */
subtitle?: string;
/** Input placeholder. Default: "Ask the agent..." */
placeholder?: string;
/** Auto-focus textarea on mount. Default: true */
autoFocus?: boolean;
/** Optional context provider for enhanced AI understanding */
getContext?: () => QuickPanelAIContext | null | Promise<QuickPanelAIContext | null>;
/** Called when user requests to close the panel */
onRequestClose?: () => void;
}
export interface QuickPanelAiChatPanelState {
sending: boolean;
streaming: boolean;
cancelling: boolean;
currentRequestId: string | null;
sessionId: string | null;
lastStatus: AgentStatusEvent['status'] | null;
lastUsage: AgentUsageStats | null;
errorMessage: string | null;
}
export interface QuickPanelAiChatPanelManager {
getState: () => QuickPanelAiChatPanelState;
focusInput: () => void;
clearMessages: () => void;
close: () => void;
dispose: () => void;
}
// ============================================================
// Constants
// ============================================================
const LOG_PREFIX = '[QuickPanelAiChatPanel]';
const DEFAULT_TITLE = 'Agent';
const DEFAULT_SUBTITLE = 'Quick Panel';
const DEFAULT_PLACEHOLDER = 'Ask the agent...';
/** Max chars for selected text context to avoid payload bloat */
const MAX_SELECTED_TEXT_CHARS = 3000;
/** Max chars for error message display */
const MAX_ERROR_DISPLAY_CHARS = 600;
const TEXTAREA_MIN_HEIGHT_PX = 42;
const TEXTAREA_MAX_HEIGHT_PX = 160;
/** Auto-hide duration for success/warning banners */
const BANNER_AUTO_HIDE_MS = 2400;
// SVG Icons
const ICON_CLOSE = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>`;
const ICON_SEND = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>`;
const ICON_STOP = `<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>`;
// ============================================================
// Utility Functions
// ============================================================
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function truncateText(text: string, maxChars: number): string {
const trimmed = text.trim();
if (trimmed.length <= maxChars) return trimmed;
return `${trimmed.slice(0, Math.max(0, maxChars - 1)).trimEnd()}\u2026`;
}
function safeFocus(element: HTMLElement): void {
try {
element.focus();
} catch {
// Best-effort focus
}
}
function isTerminalStatus(status: AgentStatusEvent['status']): boolean {
return status === 'completed' || status === 'error' || status === 'cancelled';
}
/**
* Collect default context from the current page
*/
function collectDefaultContext(): QuickPanelAIContext {
const context: QuickPanelAIContext = {
pageUrl: globalThis.location?.href,
};
try {
const selection = globalThis.getSelection?.();
const selectedText = selection?.toString()?.trim() ?? '';
if (selectedText) {
context.selectedText = truncateText(selectedText, MAX_SELECTED_TEXT_CHARS);
}
} catch {
// Ignore selection access errors
}
return context;
}
/**
* Build a local user message for optimistic rendering
*/
function buildLocalUserMessage(
sessionId: string,
requestId: string,
instruction: string,
): AgentMessage {
return {
id: `local-user:${requestId}`,
sessionId,
role: 'user',
content: instruction,
messageType: 'chat',
requestId,
isStreaming: false,
isFinal: true,
createdAt: new Date().toISOString(),
};
}
/**
* Format usage stats for display
*/
function formatUsageStats(usage: AgentUsageStats | null): string | null {
if (!usage) return null;
const parts: string[] = [];
const inputTokens = Number.isFinite(usage.inputTokens) ? usage.inputTokens : 0;
const outputTokens = Number.isFinite(usage.outputTokens) ? usage.outputTokens : 0;
parts.push(`in ${inputTokens}`, `out ${outputTokens}`);
if (Number.isFinite(usage.durationMs) && usage.durationMs > 0) {
const seconds = Math.max(1, Math.round(usage.durationMs / 1000));
parts.push(`${seconds}s`);
}
if (Number.isFinite(usage.totalCostUsd) && usage.totalCostUsd > 0) {
parts.push(`$${usage.totalCostUsd.toFixed(4)}`);
}
return parts.join(' \u2022 ');
}
// ============================================================
// DOM Builder Functions
// ============================================================
interface PanelDOMElements {
overlay: HTMLDivElement;
panel: HTMLDivElement;
titleSubEl: HTMLDivElement;
streamIndicator: HTMLDivElement;
streamText: HTMLSpanElement;
closeBtn: HTMLButtonElement;
contentEl: HTMLDivElement;
emptyEl: HTMLDivElement;
messagesEl: HTMLDivElement;
banner: HTMLDivElement;
textarea: HTMLTextAreaElement;
/** Unified action button: send/stop */
actionBtn: HTMLButtonElement;
}
function buildPanelDOM(options: QuickPanelAiChatPanelOptions): PanelDOMElements {
const title = options.title?.trim() || DEFAULT_TITLE;
const subtitle = options.subtitle?.trim() || DEFAULT_SUBTITLE;
const placeholder = options.placeholder?.trim() || DEFAULT_PLACEHOLDER;
// Overlay (click outside to close)
const overlay = document.createElement('div');
overlay.className = 'qp-overlay';
overlay.setAttribute('data-mcp-quick-panel-ai-chat', 'true');
// Panel container
const panel = document.createElement('div');
panel.className = 'qp-panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-modal', 'true');
panel.setAttribute('aria-label', title);
// ---- Header ----
const header = document.createElement('div');
header.className = 'qp-header';
const headerLeft = document.createElement('div');
headerLeft.className = 'qp-header-left';
const brand = document.createElement('div');
brand.className = 'qp-brand';
brand.textContent = '\u2726'; // Star symbol
const titleWrap = document.createElement('div');
titleWrap.className = 'qp-title';
const titleNameEl = document.createElement('div');
titleNameEl.className = 'qp-title-name';
titleNameEl.textContent = title;
const titleSubEl = document.createElement('div');
titleSubEl.className = 'qp-title-sub';
titleSubEl.textContent = subtitle;
titleWrap.append(titleNameEl, titleSubEl);
headerLeft.append(brand, titleWrap);
const headerRight = document.createElement('div');
headerRight.className = 'qp-header-right';
const streamIndicator = document.createElement('div');
streamIndicator.className = 'qp-stream-indicator';
streamIndicator.hidden = true;
const streamDot = document.createElement('span');
streamDot.className = 'qp-stream-dot ac-pulse';
const streamText = document.createElement('span');
streamText.textContent = 'Streaming';
streamIndicator.append(streamDot, streamText);
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'qp-icon-btn ac-focus-ring';
closeBtn.innerHTML = ICON_CLOSE;
closeBtn.setAttribute('aria-label', 'Close Quick Panel');
headerRight.append(streamIndicator, closeBtn);
header.append(headerLeft, headerRight);
// ---- Content ----
const contentEl = document.createElement('div');
contentEl.className = 'qp-content ac-scroll';
const emptyEl = document.createElement('div');
emptyEl.className = 'qp-empty';
const emptyIcon = document.createElement('div');
emptyIcon.className = 'qp-empty-icon';
emptyIcon.textContent = '\u2726';
const emptyText = document.createElement('div');
emptyText.className = 'qp-empty-text';
emptyText.textContent = 'Ask about this page. Streaming replies appear here.';
emptyEl.append(emptyIcon, emptyText);
const messagesEl = document.createElement('div');
messagesEl.className = 'qp-messages';
contentEl.append(emptyEl, messagesEl);
// ---- Composer ----
const composer = document.createElement('div');
composer.className = 'qp-composer';
const banner = document.createElement('div');
banner.className = 'qp-status';
banner.hidden = true;
const textarea = document.createElement('textarea');
textarea.className = 'qp-textarea ac-focus-ring';
textarea.placeholder = placeholder;
textarea.rows = 1;
const actions = document.createElement('div');
actions.className = 'qp-actions';
const actionsLeft = document.createElement('div');
actionsLeft.className = 'qp-actions-left';
// Keyboard hints
const hints = [
{ key: 'Enter', label: 'Send' },
{ key: 'Shift+Enter', label: 'New line' },
{ key: 'Esc', label: 'Close' },
];
for (const hint of hints) {
const keyEl = document.createElement('span');
keyEl.className = 'qp-kbd';
keyEl.textContent = hint.key;
const labelEl = document.createElement('span');
labelEl.textContent = hint.label;
actionsLeft.append(keyEl, labelEl);
}
const actionsRight = document.createElement('div');
actionsRight.className = 'qp-actions-right';
// Unified action button: shows send icon normally, stop icon when loading
const actionBtn = document.createElement('button');
actionBtn.type = 'button';
actionBtn.className = 'qp-icon-btn qp-icon-btn--action qp-icon-btn--primary ac-focus-ring';
actionBtn.innerHTML = ICON_SEND;
actionBtn.setAttribute('aria-label', 'Send message');
actionBtn.dataset.action = 'send';
actionsRight.append(actionBtn);
actions.append(actionsLeft, actionsRight);
composer.append(banner, textarea, actions);
// Assemble
panel.append(header, contentEl, composer);
overlay.append(panel);
return {
overlay,
panel,
titleSubEl,
streamIndicator,
streamText,
closeBtn,
contentEl,
emptyEl,
messagesEl,
banner,
textarea,
actionBtn,
};
}
// ============================================================
// Main Factory
// ============================================================
/**
* Mount the Quick Panel AI Chat interface.
*
* @example
* ```typescript
* const chatPanel = mountQuickPanelAiChatPanel({
* mount: shadowHostElements.root,
* agentBridge,
* onRequestClose: () => quickPanel.hide(),
* });
*
* // Later: clean up
* chatPanel.dispose();
* ```
*/
export function mountQuickPanelAiChatPanel(
options: QuickPanelAiChatPanelOptions,
): QuickPanelAiChatPanelManager {
const disposer = new Disposer();
const mount = options.mount;
const agentBridge = options.agentBridge;
const defaultSubtitle = options.subtitle?.trim() || DEFAULT_SUBTITLE;
// Clean up any existing panel in same mount (crash recovery)
try {
const existing = mount.querySelector?.('[data-mcp-quick-panel-ai-chat="true"]');
if (existing instanceof HTMLElement) {
existing.remove();
}
} catch {
// Ignore cleanup errors
}
// --------------------------------------------------------
// State Management
// --------------------------------------------------------
let disposed = false;
let requestUnsubscribe: (() => void) | null = null;
let bannerTimer: ReturnType<typeof setTimeout> | null = null;
let state: QuickPanelAiChatPanelState = {
sending: false,
streaming: false,
cancelling: false,
currentRequestId: null,
sessionId: null,
lastStatus: null,
lastUsage: null,
errorMessage: null,
};
// --------------------------------------------------------
// DOM Setup
// --------------------------------------------------------
const dom = buildPanelDOM(options);
mount.append(dom.overlay);
disposer.add(() => dom.overlay.remove());
// Message renderer
const renderer: QuickPanelMessageRenderer = createQuickPanelMessageRenderer({
container: dom.messagesEl,
scrollContainer: dom.contentEl,
autoScroll: true,
autoScrollThresholdPx: 96,
});
disposer.add(() => renderer.dispose());
// --------------------------------------------------------
// Banner Management
// --------------------------------------------------------
function clearBannerTimer(): void {
if (bannerTimer) {
clearTimeout(bannerTimer);
bannerTimer = null;
}
}
function hideBanner(): void {
clearBannerTimer();
dom.banner.hidden = true;
dom.banner.className = 'qp-status';
dom.banner.textContent = '';
}
function showBanner(
tone: 'info' | 'success' | 'warning' | 'error',
message: string,
autoHideMs?: number,
): void {
clearBannerTimer();
dom.banner.hidden = false;
dom.banner.className = 'qp-status';
if (tone === 'error') dom.banner.classList.add('qp-status--error');
if (tone === 'success') dom.banner.classList.add('qp-status--success');
if (tone === 'warning') dom.banner.classList.add('qp-status--warning');
dom.banner.textContent = message;
if (autoHideMs && autoHideMs > 0) {
bannerTimer = setTimeout(hideBanner, autoHideMs);
}
}
// --------------------------------------------------------
// Textarea Auto-resize
// --------------------------------------------------------
function resizeTextarea(): void {
try {
dom.textarea.style.height = 'auto';
const targetHeight = Math.min(
TEXTAREA_MAX_HEIGHT_PX,
Math.max(TEXTAREA_MIN_HEIGHT_PX, dom.textarea.scrollHeight),
);
dom.textarea.style.height = `${targetHeight}px`;
} catch {
// Ignore resize errors
}
}
// --------------------------------------------------------
// UI Rendering
// --------------------------------------------------------
function renderEmptyState(): void {
const hasMessages = renderer.getMessageCount() > 0;
dom.emptyEl.hidden = hasMessages;
dom.messagesEl.hidden = !hasMessages;
}
function renderHeaderSubtitle(): void {
if (state.errorMessage) {
dom.titleSubEl.textContent = 'Error';
return;
}
if (state.streaming) {
dom.titleSubEl.textContent = 'Streaming\u2026';
return;
}
if (state.sending) {
dom.titleSubEl.textContent = 'Sending\u2026';
return;
}
const usageText = formatUsageStats(state.lastUsage);
dom.titleSubEl.textContent = usageText ? `Last: ${usageText}` : defaultSubtitle;
}
function renderControls(): void {
const inputText = dom.textarea.value.trim();
const isLoading = state.sending || state.streaming || state.cancelling;
const canSend = inputText.length > 0 && !isLoading;
const canCancel = state.currentRequestId !== null && !state.cancelling;
// Update action button state and appearance
if (isLoading) {
// Show stop icon when loading/streaming
dom.actionBtn.innerHTML = ICON_STOP;
dom.actionBtn.setAttribute('aria-label', 'Stop request');
dom.actionBtn.dataset.action = 'stop';
dom.actionBtn.disabled = !canCancel;
dom.actionBtn.classList.remove('qp-icon-btn--primary');
dom.actionBtn.classList.add('qp-icon-btn--danger');
} else {
// Show send icon when idle
dom.actionBtn.innerHTML = ICON_SEND;
dom.actionBtn.setAttribute('aria-label', 'Send message');
dom.actionBtn.dataset.action = 'send';
dom.actionBtn.disabled = !canSend;
dom.actionBtn.classList.remove('qp-icon-btn--danger');
dom.actionBtn.classList.add('qp-icon-btn--primary');
}
// Stream indicator
dom.streamIndicator.hidden = !isLoading;
if (state.cancelling) {
dom.streamText.textContent = 'Cancelling';
} else if (state.sending) {
dom.streamText.textContent = 'Sending';
} else {
dom.streamText.textContent = 'Streaming';
}
// Allow typing while streaming; disable only during send/cancel
dom.textarea.disabled = state.sending || state.cancelling;
renderHeaderSubtitle();
renderEmptyState();
}
function setState(patch: Partial<QuickPanelAiChatPanelState>): void {
state = { ...state, ...patch };
renderControls();
}
// --------------------------------------------------------
// Subscription Management
// --------------------------------------------------------
function cleanupActiveSubscription(): void {
if (requestUnsubscribe) {
try {
requestUnsubscribe();
} catch {
// Ignore cleanup errors
}
requestUnsubscribe = null;
}
}
// --------------------------------------------------------
// Context Resolution
// --------------------------------------------------------
async function resolveContext(): Promise<QuickPanelAIContext | undefined> {
// Try custom context provider
try {
if (options.getContext) {
const provided = await options.getContext();
if (provided && typeof provided === 'object') {
return provided;
}
}
} catch (err) {
console.warn(`${LOG_PREFIX} getContext failed:`, err);
}
// Fallback to default context
const fallback = collectDefaultContext();
// Don't send empty context
if (!isNonEmptyString(fallback.pageUrl) && !isNonEmptyString(fallback.selectedText)) {
return undefined;
}
return fallback;
}
// --------------------------------------------------------
// Request Lifecycle
// --------------------------------------------------------
async function sendCurrentInput(): Promise<void> {
if (disposed) return;
if (state.sending || state.streaming || state.cancelling) return;
const instruction = dom.textarea.value.trim();
if (!instruction) return;
// Clear previous errors
setState({ errorMessage: null, lastUsage: null, lastStatus: null });
hideBanner();
// Save input for restoration on failure
const savedInput = dom.textarea.value;
dom.textarea.value = '';
resizeTextarea();
setState({ sending: true });
// Resolve context
const context = await resolveContext();
if (disposed) return;
const payload: QuickPanelSendToAIPayload = {
instruction,
context: context ?? undefined,
};
// Send to agent
const result = await agentBridge.sendToAI(payload);
if (disposed) return;
if (!result.success) {
// Restore input on failure
dom.textarea.value = savedInput;
resizeTextarea();
const errorMsg = truncateText(result.error, MAX_ERROR_DISPLAY_CHARS);
setState({ sending: false, errorMessage: errorMsg });
showBanner('error', errorMsg);
return;
}
// Optimistic user message rendering
// Note: Server will also echo user message; we render locally for instant feedback
// and skip server-echoed user messages in handleRequestEvent
renderer.upsert(buildLocalUserMessage(result.sessionId, result.requestId, instruction));
renderer.scrollToBottom();
setState({
sending: false,
streaming: true,
currentRequestId: result.requestId,
sessionId: result.sessionId,
lastStatus: 'starting',
});
// Subscribe to events
cleanupActiveSubscription();
requestUnsubscribe = agentBridge.onRequestEvent(result.requestId, (event) => {
if (disposed) return;
handleRequestEvent(event);
});
}
async function cancelCurrentRequest(): Promise<void> {
if (disposed) return;
if (!state.currentRequestId) return;
if (state.cancelling) return;
const requestId = state.currentRequestId;
const sessionId = state.sessionId || undefined;
setState({ cancelling: true });
const result = await agentBridge.cancelRequest(requestId, sessionId);
if (disposed) return;
if (!result.success) {
const errorMsg = truncateText(result.error, MAX_ERROR_DISPLAY_CHARS);
setState({ cancelling: false, errorMessage: errorMsg });
showBanner('error', errorMsg);
return;
}
// Cancellation completion will be driven by the 'cancelled' status event
setState({ cancelling: false });
}
function handleTerminal(status: AgentStatusEvent['status'], message?: string): void {
cleanupActiveSubscription();
setState({
streaming: false,
sending: false,
cancelling: false,
currentRequestId: null,
sessionId: null,
lastStatus: status,
});
if (status === 'completed') {
const usageText = formatUsageStats(state.lastUsage);
const bannerMsg = usageText ? `Completed \u2022 ${usageText}` : 'Completed';
showBanner('success', bannerMsg, BANNER_AUTO_HIDE_MS);
return;
}
if (status === 'cancelled') {
showBanner('warning', 'Cancelled', BANNER_AUTO_HIDE_MS);
return;
}
if (status === 'error') {
const errorMsg = truncateText(
message || state.errorMessage || 'Request failed',
MAX_ERROR_DISPLAY_CHARS,
);
setState({ errorMessage: errorMsg });
showBanner('error', errorMsg);
}
}
/**
* Handle incoming RealtimeEvent with runtime guards for malformed data.
*/
function handleRequestEvent(event: RealtimeEvent): void {
if (disposed) return;
// Runtime guard: validate event structure
if (!event || typeof event !== 'object' || !('type' in event)) {
console.warn(`${LOG_PREFIX} Invalid event structure:`, event);
return;
}
try {
switch (event.type) {
case 'message': {
const msg = event.data;
// Runtime guard: validate message structure
if (!msg || typeof msg !== 'object' || typeof msg.id !== 'string') {
console.warn(`${LOG_PREFIX} Invalid message data:`, msg);
return;
}
// For user messages from server, replace local optimistic message
// This preserves server-side metadata (cliSource, etc.)
if (msg.role === 'user') {
const localUserId = `local-user:${msg.requestId}`;
renderer.remove(localUserId);
}
renderer.upsert(msg);
if (msg.isStreaming === true && !msg.isFinal) {
setState({ streaming: true });
}
return;
}
case 'status': {
const statusData = event.data;
// Runtime guard: validate status data
if (
!statusData ||
typeof statusData !== 'object' ||
typeof statusData.status !== 'string'
) {
console.warn(`${LOG_PREFIX} Invalid status data:`, statusData);
return;
}
setState({ lastStatus: statusData.status });
if (
statusData.status === 'starting' ||
statusData.status === 'ready' ||
statusData.status === 'running'
) {
setState({ streaming: true });
return;
}
if (isTerminalStatus(statusData.status)) {
handleTerminal(statusData.status, statusData.message);
}
return;
}
case 'usage': {
setState({ lastUsage: event.data });
return;
}
case 'error': {
const errorMsg = truncateText(event.error || 'Unknown error', MAX_ERROR_DISPLAY_CHARS);
setState({ errorMessage: errorMsg });
showBanner('error', errorMsg);
cleanupActiveSubscription();
setState({
streaming: false,
sending: false,
cancelling: false,
currentRequestId: null,
sessionId: null,
lastStatus: 'error',
});
return;
}
case 'connected':
case 'heartbeat': {
// These events are typically filtered by background, but handle exhaustively
return;
}
}
} catch (err) {
// Catch any unexpected errors to prevent UI crash
console.warn(`${LOG_PREFIX} Error handling event:`, err, event);
}
}
// --------------------------------------------------------
// Event Handlers
// --------------------------------------------------------
disposer.listen(dom.overlay, 'click', (ev: MouseEvent) => {
if (disposed) return;
// Close on backdrop click
if (ev.target === dom.overlay) {
close();
}
});
disposer.listen(dom.closeBtn, 'click', () => close());
// Unified action button handler: send or stop based on current state
disposer.listen(dom.actionBtn, 'click', () => {
if (disposed) return;
const action = dom.actionBtn.dataset.action;
if (action === 'stop') {
void cancelCurrentRequest();
} else {
void sendCurrentInput();
}
});
disposer.listen(dom.textarea, 'input', () => {
if (disposed) return;
resizeTextarea();
renderControls();
});
disposer.listen(dom.textarea, 'keydown', (ev: KeyboardEvent) => {
if (disposed) return;
// Esc closes the panel
if (ev.key === 'Escape' && !ev.isComposing) {
ev.preventDefault();
close();
return;
}
// Enter sends, Shift+Enter inserts newline
if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) {
ev.preventDefault();
void sendCurrentInput();
}
});
// --------------------------------------------------------
// Public API
// --------------------------------------------------------
function focusInput(): void {
if (disposed) return;
safeFocus(dom.textarea);
}
function clearMessages(): void {
if (disposed) return;
renderer.clear();
hideBanner();
setState({ lastUsage: null, lastStatus: null, errorMessage: null });
}
function close(): void {
if (disposed) return;
// Best-effort cancel on close
if (state.currentRequestId) {
void cancelCurrentRequest();
}
try {
options.onRequestClose?.();
} catch (err) {
console.warn(`${LOG_PREFIX} onRequestClose failed:`, err);
}
dispose();
}
function dispose(): void {
if (disposed) return;
disposed = true;
cleanupActiveSubscription();
clearBannerTimer();
disposer.dispose();
}
// --------------------------------------------------------
// Initialization
// --------------------------------------------------------
resizeTextarea();
renderControls();
if (options.autoFocus !== false) {
focusInput();
}
return {
getState: () => ({ ...state }),
focusInput,
clearMessages,
close,
dispose,
};
}