nanobot-voice-interface/frontend/src/components/LogPanel.tsx
2026-03-12 09:25:15 -04:00

199 lines
4.9 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import type { LogLine } from "../types";
interface Props {
lines: LogLine[];
disabled: boolean;
onSendMessage(text: string): Promise<void>;
onExpandChange?(expanded: boolean): void;
}
interface LogViewProps {
lines: LogLine[];
scrollRef: { current: HTMLElement | null };
}
function SendIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M2 9L16 2L9.5 16L8 10.5L2 9Z" fill="currentColor" />
</svg>
);
}
function CloseIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M14 4L4 14M4 4L14 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function formatLine(line: LogLine): string {
const time = line.timestamp ? new Date(line.timestamp).toLocaleTimeString() : "";
const role = line.role.trim().toLowerCase();
if (role === "nanobot") {
return `[${time}] ${line.text.replace(/^(?:nanobot|napbot)\b\s*[:>-]?\s*/i, "")}`;
}
if (role === "tool") {
return `[${time}] tool: ${line.text}`;
}
return `[${time}] ${line.role}: ${line.text}`;
}
function LogCompose({
disabled,
sending,
text,
setText,
onClose,
onSend,
}: {
disabled: boolean;
sending: boolean;
text: string;
setText(value: string): void;
onClose(): void;
onSend(): void;
}) {
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSend();
}
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
},
[onClose, onSend],
);
return (
<div id="log-compose">
<textarea
id="log-compose-input"
placeholder="Type a message to nanobot..."
disabled={disabled || sending}
value={text}
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
onKeyDown={onKeyDown}
/>
<div id="log-compose-actions">
<button id="log-close-btn" type="button" aria-label="Close" onClick={onClose}>
<CloseIcon />
</button>
<button
id="log-send-btn"
type="button"
aria-label="Send message"
disabled={disabled || sending || text.trim().length === 0}
onClick={onSend}
>
<SendIcon />
</button>
</div>
</div>
);
}
function ExpandedLogView({ lines, scrollRef }: LogViewProps) {
return (
<div
id="log-scroll"
ref={(node) => {
scrollRef.current = node;
}}
>
<div id="log-inner">
{lines.map((line) => (
<div key={line.id} class={`line ${line.role}`}>
{formatLine(line)}
</div>
))}
</div>
</div>
);
}
function CollapsedLogView({ lines, scrollRef, onExpand }: LogViewProps & { onExpand(): void }) {
return (
<button
id="log-collapsed"
ref={(node) => {
scrollRef.current = node;
}}
type="button"
aria-label="Open message composer"
onClick={onExpand}
>
<div id="log-inner">
{lines.map((line) => (
<span key={line.id} class={`line ${line.role}`}>
{formatLine(line)}
</span>
))}
</div>
</button>
);
}
export function LogPanel({ lines, disabled, onSendMessage, onExpandChange }: Props) {
const [expanded, setExpanded] = useState(false);
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLElement>(null);
useEffect(() => onExpandChange?.(expanded), [expanded, onExpandChange]);
useEffect(() => () => onExpandChange?.(false), [onExpandChange]);
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines, expanded]);
const collapse = useCallback(() => {
setExpanded(false);
setText("");
}, []);
const expand = useCallback(() => {
if (!expanded) setExpanded(true);
}, [expanded]);
const send = useCallback(async () => {
const message = text.trim();
if (!message || sending || disabled) return;
setSending(true);
try {
await onSendMessage(message);
setText("");
} catch (err) {
window.alert(`Could not send message: ${String(err)}`);
} finally {
setSending(false);
}
}, [disabled, onSendMessage, sending, text]);
return (
<div id="log" class={expanded ? "expanded" : ""} data-no-swipe="1">
{expanded ? (
<ExpandedLogView lines={lines} scrollRef={scrollRef} />
) : (
<CollapsedLogView lines={lines} scrollRef={scrollRef} onExpand={expand} />
)}
{expanded && (
<LogCompose
disabled={disabled}
sending={sending}
text={text}
setText={setText}
onClose={collapse}
onSend={() => {
void send();
}}
/>
)}
</div>
);
}