diff --git a/src/extensions/index.ts b/src/extensions/index.ts index 0610f89..46e35ce 100644 --- a/src/extensions/index.ts +++ b/src/extensions/index.ts @@ -8,5 +8,9 @@ export type { SidebarExtension, RouteExtension, SettingsSectionExtension, + ChatBeforeSendContext, + ChatBeforeSendResult, + ChatComposerStatusProps, + ChatExtension, I18nResources, } from './types'; diff --git a/src/extensions/registry.ts b/src/extensions/registry.ts index b53f6d4..246e83f 100644 --- a/src/extensions/registry.ts +++ b/src/extensions/registry.ts @@ -1,10 +1,13 @@ -import i18n from '@/i18n'; +import i18n from 'i18next'; import type { RendererExtension, NavItemDef, RouteDef, SettingsSectionDef, SkillDetailMetaProps, + ChatComposerStatusProps, + ChatBeforeSendContext, + ChatBeforeSendResult, } from './types'; import type { ComponentType } from 'react'; @@ -62,6 +65,33 @@ class RendererExtensionRegistry { return this.extensions.flatMap((ext) => ext.skills?.detailMetaComponents ?? []); } + getChatComposerStatusComponents(): ComponentType[] { + return this.extensions.flatMap((ext) => ext.chat?.composerStatusComponents ?? []); + } + + hasChatBeforeSendHooks(): boolean { + return this.extensions.some((ext) => (ext.chat?.beforeSend?.length ?? 0) > 0); + } + + async runChatBeforeSend(context: ChatBeforeSendContext): Promise { + for (const ext of this.extensions) { + for (const hook of ext.chat?.beforeSend ?? []) { + try { + const result = await hook(context); + if (!result.ok) { + return result; + } + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : String(err), + }; + } + } + } + return { ok: true }; + } + async initializeAll(): Promise { for (const ext of this.extensions) { try { diff --git a/src/extensions/types.ts b/src/extensions/types.ts index a5d5ac4..7052dd6 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -1,5 +1,6 @@ import type { ComponentType } from 'react'; import type { Skill } from '../types/skill'; +import type { GatewayStatus } from '../types/gateway'; export interface NavItemDef { to: string; @@ -47,12 +48,36 @@ export interface SkillsExtension { detailMetaComponents?: ComponentType[]; } +export interface ChatBeforeSendContext { + text: string; + attachments?: unknown[]; + targetAgentId?: string | null; +} + +export interface ChatBeforeSendResult { + ok: boolean; + message?: string; +} + +export interface ChatComposerStatusProps { + gatewayStatus: GatewayStatus; +} + +export interface ChatExtension { + id: string; + composerStatusComponents?: ComponentType[]; + beforeSend?: Array<( + context: ChatBeforeSendContext, + ) => ChatBeforeSendResult | Promise>; +} + export interface RendererExtension { id: string; sidebar?: SidebarExtension; routes?: RouteExtension; settings?: SettingsSectionExtension; skills?: SkillsExtension; + chat?: ChatExtension; i18nResources?: I18nResources; setup?(): void | Promise; teardown?(): void; diff --git a/src/pages/Chat/ChatInput.tsx b/src/pages/Chat/ChatInput.tsx index c1a479d..9de5437 100644 --- a/src/pages/Chat/ChatInput.tsx +++ b/src/pages/Chat/ChatInput.tsx @@ -18,6 +18,8 @@ import { useAgentsStore } from '@/stores/agents'; import { useChatStore } from '@/stores/chat'; import type { AgentSummary } from '@/types/agent'; import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { rendererExtensionRegistry } from '@/extensions/registry'; // ── Types ──────────────────────────────────────────────────────── @@ -109,6 +111,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i [agents, targetAgentId], ); const showAgentPicker = mentionableAgents.length > 0; + const chatComposerStatusComponents = rendererExtensionRegistry.getChatComposerStatusComponents(); // Auto-resize textarea useEffect(() => { @@ -288,13 +291,28 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i const canSend = (input.trim() || attachments.length > 0) && allReady && !disabled && !sending; const canStop = sending && !disabled && !!onStop; - const handleSend = useCallback(() => { + const handleSend = useCallback(async () => { if (!canSend) return; const readyAttachments = attachments.filter(a => a.status === 'ready'); - // Capture values before clearing — clear input immediately for snappy UX, - // but keep attachments available for the async send const textToSend = input.trim(); const attachmentsToSend = readyAttachments.length > 0 ? readyAttachments : undefined; + + if (rendererExtensionRegistry.hasChatBeforeSendHooks()) { + const guard = await rendererExtensionRegistry.runChatBeforeSend({ + text: textToSend, + attachments: attachmentsToSend, + targetAgentId, + }); + if (!guard.ok) { + if (guard.message) { + toast.error(guard.message); + } + return; + } + } + + // Capture values before clearing — clear input immediately for snappy UX, + // but keep attachments available for the async send console.log(`[handleSend] text="${textToSend.substring(0, 50)}", attachments=${attachments.length}, ready=${readyAttachments.length}, sending=${!!attachmentsToSend}`); if (attachmentsToSend) { console.log('[handleSend] Attachment details:', attachmentsToSend.map(a => ({ @@ -530,6 +548,9 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i pid: gatewayStatus.pid ? `| pid: ${gatewayStatus.pid}` : '', })} + {chatComposerStatusComponents.map((Component, index) => ( + + ))} {hasFailedAttachments && (