import React, { useEffect, useRef, useState } from 'react'; import type { OutputField as OutputFieldData } from '../types'; import { KEYBOARD_HINT, useDragResize } from '../hooks/useDragResize'; import { useUIF } from './UIFContext'; import { ResizeCorners } from './ResizeCorners'; interface ViewProps { field: OutputFieldData; active: boolean; flash: number; popped?: boolean; onHeaderPointerDown?: (e: React.PointerEvent) => void; /** Keyboard move/resize for the header; makes the header focusable. */ onHeaderKeyDown?: (e: React.KeyboardEvent) => void; onRename?: (title: string) => void; /** Send a chat message into this output. Shows a reply box when set. */ onSubmit?: (text: string) => void; /** Keep a reply in .memory. Shows a โ—ˆ button on replies when set. */ onRemember?: (text: string) => void; onPopOut?: () => void; /** Read replies aloud: shows a ๐Ÿ”Š toggle when set. */ onSpeak?: () => void; onHome?: () => void; /** Fill the screen or shrink back: a โคข button, and a double-click on the header (the title still renames). */ onMaximize?: () => void; onClose: () => void; } /** Header and message list, shared by the in-page panel and the pop-out window. */ export function OutputView({ field, active, flash, popped, onHeaderPointerDown, onHeaderKeyDown, onRename, onSubmit, onRemember, onPopOut, onSpeak, onHome, onMaximize, onClose, }: ViewProps) { const [kept, setKept] = useState>({}); const listRef = useRef(null); const [editing, setEditing] = useState(false); const [reply, setReply] = useState(''); const busy = field.messages.some(m => m.pending); const finishRename = (title: string | null) => { setEditing(false); if (title !== null && title.trim() && title.trim() !== field.title) onRename?.(title); }; const submit = (e: React.FormEvent) => { e.preventDefault(); if (!reply.trim() || busy) return; onSubmit?.(reply); setReply(''); }; const last = field.messages[field.messages.length - 1]; useEffect(() => { const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; }, [field.messages.length, last?.text]); return ( <> {flash > 0 &&
}
{ // the title's own double-click renames; anywhere else on the header maximizes or shrinks back if (!editing && onMaximize && !(e.target as HTMLElement).closest('.uif-output-title, .uif-output-buttons')) onMaximize(); }} tabIndex={onHeaderKeyDown ? 0 : undefined} role={onHeaderKeyDown ? 'group' : undefined} aria-label={onHeaderKeyDown ? `${field.title} output. ${KEYBOARD_HINT}` : undefined} >
{field.messages.length === 0 &&
No messages yet.
} {field.messages.map(m => { if (m.role === 'system' && !m.text && !m.pending) return null; // a status line the engine cleared const keep = onRemember && m.role === 'assistant' && !m.pending && !m.undelivered && m.text; const msg = (
{m.text} {m.pending && โ–} {m.media && ( {m.media.kind === 'video' ? )} {m.silent && field.speak && ( {' '}๐Ÿ”‡ )}
); if (!keep) return msg; // The keep button sits beside the reply, not inside it, so copying the reply copies only the reply. return (
{msg}
); })}
{onSubmit && (
setReply(e.target.value)} placeholder={`Reply in ${field.title}โ€ฆ`} aria-label={`Message ${field.title}`} className="uif-output-reply-input" />
)} ); } /** A floating, draggable output panel inside the hub page. */ export function OutputField({ field }: { field: OutputFieldData }) { const { outputs, setRect, flashes, raise, zIndexOf, sendTo, context } = useUIF(); const { position, size, setPosition, setSize, startDrag, startResize, onHandleKeyDown, isDragging } = useDragResize({ initialPosition: { x: field.rect.x, y: field.rect.y }, initialSize: { width: field.rect.width, height: field.rect.height }, minWidth: 220, minHeight: 120, onCommit: rect => setRect(field.id, rect), }); // Follow rect changes made elsewhere (call home restacks the panels). useEffect(() => { setPosition({ x: field.rect.x, y: field.rect.y }); setSize({ width: field.rect.width, height: field.rect.height }); }, [field.rect, setPosition, setSize]); const active = outputs.activeId === field.id; const max = !!field.maximized; // Esc shrinks the window that fills the screen back to where it was useEffect(() => { if (!max) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && outputs.activeId === field.id) outputs.maximize(field.id, false); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [max, field.id, outputs]); return (
{ outputs.setActive(field.id); raise(field.id); }} > outputs.maximize(field.id)} onRename={title => outputs.rename(field.id, title)} onSubmit={text => sendTo(text, field.id)} onRemember={text => void context.remember(text, 'response')} onPopOut={() => outputs.popOut(field.id)} onSpeak={() => outputs.setSpeak(field.id, !field.speak)} onClose={() => outputs.close(field.id)} /> {!max && }
); }