'use client'; /** * MAIC Agent — editor AI sidebar (right rail), "Edit with AI" Cursor-style * surface per the OpenMAIC AgentSidebar design board: * - user messages are right-aligned solid-violet bubbles (radius 14/14/4/14); * - assistant output is full-width markdown with design-language tool cards in * chronological order; * - the composer is a bordered shell with a violet focus glow, an @-context * chip for the active scene, horizontally-scrolling quick-prompt chips, and a * square violet send button. * Only design aspects with real V0 backing are implemented — the model picker, * Agent/Ask mode, checkpoints/Restore, reasoning blocks and per-element @-chips * from the board are intentionally omitted (no runtime support yet). * Wiring (ExternalStore over the pi AgentEvent SSE stream) lives in * use-agent-runtime. */ import { useCallback, useRef, useState } from 'react'; import { AssistantRuntimeProvider, ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useComposerRuntime, useMessage, type AssistantRuntime, } from '@assistant-ui/react'; import { ArrowUp, AtSign, ChevronDown, History, PanelRightClose, PanelRightOpen, Sparkles, Square, SquarePen, Trash2, } from 'lucide-react'; import { cn } from '@/lib/utils/cn'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import type { AgentEditSessionRecord } from '@/lib/agent/client/agent-edit-session-types'; import { useI18n } from '@/lib/hooks/use-i18n'; import { SpeechButton } from '@/components/audio/speech-button'; import { MarkdownText } from './markdown-text'; import { ReasoningPart } from './reasoning-part'; import { RegenerateSceneActionsUI } from './regenerate-tool-ui'; import { RegenerateSceneUI } from './regenerate-scene-tool-ui'; import { EditInteractiveHtmlUI } from './edit-interactive-html-tool-ui'; import { ReadSceneContentUI } from './read-tool-ui'; const MIN_WIDTH = 320; const MAX_WIDTH = 640; const DEFAULT_WIDTH = 384; /** Capability rows shown in the empty state — read-only tips (not clickable), * each a label + example phrasings. One unified list describing what the agent * can do across scenes (slide content + narration + interactive-page fixing), * shown regardless of the active scene type. */ const CAPABILITY_KEYS = [ { label: 'edit.agent.cap.content.label', examples: 'edit.agent.cap.content.examples' }, { label: 'edit.agent.cap.narration.label', examples: 'edit.agent.cap.narration.examples' }, { label: 'edit.agent.cap.fixHtml.label', examples: 'edit.agent.cap.fixHtml.examples' }, ]; function UserMessage() { return ( {/* Solid brand-violet bubble, right-aligned, with a tail toward the user (radius 14/14/4/14) — per the design board's .ae-user. */}
); } function ThinkingIndicator() { const { t } = useI18n(); // Cursor-style shimmer label — the bright band sweeps across the word while we // wait for the next API call's first streamed token. Reasoning tokens are never // rendered raw (think-blocks stripped upstream); thinking surfaces only here. return ( {t('edit.agent.thinking')} ); } function AssistantMessage() { const { t } = useI18n(); // Separate primitive selectors — useMessage is backed by useSyncExternalStore // (Object.is snapshot compare), so returning a fresh object literal would loop. const hasContent = useMessage((m) => m.content.some( (p) => (p.type === 'text' && p.text.length > 0) || p.type === 'tool-call' || (p.type === 'reasoning' && p.text.length > 0), ), ); // Loading shows only while a NEW API call is pending its first token — not while // tokens are streaming. Walk to the last meaningful part: live text → streaming // (no loading); a finished tool call (result present) → next turn pending // (loading); a still-running tool call → its card already spins (no loading); // nothing yet → loading. const showLoading = useMessage((m) => { if (m.status?.type !== 'running') return false; const parts = m.content as Array<{ type: string; text?: string; result?: unknown }>; for (let i = parts.length - 1; i >= 0; i--) { const p = parts[i]; if (p.type === 'text' && typeof p.text === 'string' && p.text.length > 0) return false; if (p.type === 'tool-call') return p.result !== undefined; // A reasoning part shows its own live "thinking…" label (with duration), // so the separate shimmer indicator is redundant while reasoning streams. if (p.type === 'reasoning' && typeof p.text === 'string' && p.text.length > 0) return false; } return true; }); const stopped = useMessage((m) => m.status?.type !== 'running'); return ( {hasContent ? (
) : null} {showLoading ? ( ) : stopped && !hasContent ? ( {t('edit.agent.stopped')} ) : null}
); } /** Mic button that dictates into the composer. Lives inside ComposerPrimitive.Root * so it can append the transcription to the composer text via the composer * runtime. SpeechButton self-gates on ASR availability (disabled when off). */ function VoiceInputButton({ disabled }: { readonly disabled?: boolean }) { const composer = useComposerRuntime(); return ( { if (!text) return; const cur = composer.getState().text ?? ''; const sep = cur && !cur.endsWith(' ') ? ' ' : ''; composer.setText(cur + sep + text); }} /> ); } interface AgentPanelProps { readonly scene?: { id: string; title: string; type?: string }; readonly runtime: AssistantRuntime; readonly clearThread: () => void; readonly hasMessages: boolean; readonly canSend: boolean; readonly sessions: AgentEditSessionRecord[]; readonly activeSessionId: string | undefined; readonly switchSession: (id: string) => Promise; readonly deleteSessionAndRefresh: (id: string) => Promise; readonly refreshSessions: () => Promise; /** * When true, renders only the thread body (no aside wrapper, no header, no * resize handle, no collapse state). Used when an outer container (e.g. * RightRailTabs) owns the rail chrome. */ readonly naked?: boolean; } export function AgentPanel({ scene, runtime, clearThread, hasMessages, canSend, sessions, activeSessionId, switchSession, deleteSessionAndRefresh, refreshSessions, naked, }: AgentPanelProps) { const { t } = useI18n(); // Interactive scenes expose a different agent capability (fix the page's bugs) // than slides (regenerate content/narration), so the empty-state copy and the // composer placeholder switch by scene type. // Empty-state copy is unified (no slide/interactive split) — the capability // list above already covers fixing interactive pages. The composer placeholder // still adapts to the active scene type. const isInteractive = scene?.type === 'interactive'; const capabilityKeys = CAPABILITY_KEYS; const emptyTitleKey = 'edit.agent.emptyTitle'; const emptyLeadKey = 'edit.agent.empty.lead'; const emptyBoundaryKey = 'edit.agent.empty.boundary'; const placeholderKey = isInteractive ? 'edit.agent.interactive.placeholder' : 'edit.agent.placeholder'; const sceneTypeLabel = scene?.type && ['slide', 'quiz', 'interactive', 'pbl'].includes(scene.type) ? t(`edit.sceneType.${scene.type}`) : (scene?.type ?? 'Scene'); const unsupportedMessage = t('edit.unsupportedScene', { type: sceneTypeLabel }); // Drag-to-resize from the left edge (pointer capture, direct DOM write). const railRef = useRef(null); const [width, setWidth] = useState(DEFAULT_WIDTH); const dragRef = useRef<{ startX: number; startW: number; lastW: number; pointerId: number; } | null>(null); const onResizeStart = useCallback( (e: React.PointerEvent) => { const startW = railRef.current?.getBoundingClientRect().width ?? width; dragRef.current = { startX: e.clientX, startW, lastW: startW, pointerId: e.pointerId }; try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* best effort */ } document.body.style.cursor = 'col-resize'; }, [width], ); const onResizeMove = useCallback((e: React.PointerEvent) => { const d = dragRef.current; if (!d || e.pointerId !== d.pointerId) return; const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, d.startW + (d.startX - e.clientX))); d.lastW = next; if (railRef.current) railRef.current.style.width = `${next}px`; }, []); const onResizeEnd = useCallback((e: React.PointerEvent) => { const d = dragRef.current; if (!d || e.pointerId !== d.pointerId) return; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* may already be released */ } setWidth(d.lastW); dragRef.current = null; document.body.style.cursor = ''; }, []); const [collapsed, setCollapsed] = useState(false); // Naked mode: outer container (RightRailTabs) owns the aside wrapper and chrome. // Render only the thread body, no aside / header / resize / collapse state. if (naked) { return (

{t(emptyTitleKey)}

{t(emptyLeadKey)}

{capabilityKeys.map(({ label, examples }) => (
{t(label)} {t(examples)}
))}

{t(emptyBoundaryKey)}

{t('edit.agent.empty.comingSoon')}

{!canSend ? (

{unsupportedMessage}

) : null} {scene?.title ? (
{scene.title}
) : null}
); } // Collapsed: a slim rail with the brand mark — click anywhere to reopen. The // runtime is owned above this panel, so the conversation is preserved. if (collapsed) { return ( ); } return ( ); }