199 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|