'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 (
);
}