Ported from app.js's `renderTermInput`/`handleSlashCommand`/
`completeSlash`: prompt + auto-growing textarea, Enter sends (Shift+Enter
newline), Tab cycles slash-command completion. Same command set
(/help, /clear, /cancel, /compact, /model, /effort, /new-session,
/logout), same routes (`api/cancel`, `api/compact`, `api/new-session`,
`api/logout`, `send`) via new `lib/termActions.ts` (same `{ok, detail}`
shape as modelEffort.ts's POST helper).
One deliberate UX change: `/new-session` and `/logout` used to pop the
old shadow-DOM `themedConfirm` modal before firing — this rewrite's
destructive actions all avoid that widget family already (SidePanel,
useConfirmClick), and a modal doesn't fit a text-input flow anyway.
Typing the command once arms it (a local note explains what confirming
does); typing it again fires it — a keyboard-native two-step confirm.
`/help`/`/clear` need to reach into LiveStream's row list (local-only
echo rows, never sent anywhere) without lifting that state up to Root —
`useLiveStream` gained `pushLocalNote`/`clearLocal`, exposed off
`LiveStream` via `forwardRef`+`useImperativeHandle` (preact/compat),
same shape as app.js's old `termAPI` object but scoped as a ref handle
instead of a module-level variable.
Screenshot-verified end to end: typed "/help" + Enter into a real
mounted composer, confirmed the textarea clears and the local note rows
(command list) append to the live pane.
127 lines
4.9 KiB
TypeScript
127 lines
4.9 KiB
TypeScript
// Live event pane — Preact owner of the DOM node `useLiveStream`'s rows
|
|
// get rendered into. Deliberately NOT the old MutationObserver + rAF
|
|
// snap-animation + `smoothScrollingUntil` gate machinery
|
|
// (@hive/shared/terminal.js) — mara explicitly flagged that the old
|
|
// page's autoscroll breaks when new events land while the operator has
|
|
// scrolled up, and not to copy it 1:1. This is the "do it properly the
|
|
// preact way" version:
|
|
//
|
|
// - `stickToBottom` is plain state, updated synchronously from the
|
|
// scroll handler (near-bottom → true, scrolled away → false). No
|
|
// separate "am I mid-animation" gate to fight with it — there's no
|
|
// animation to fight, snapping is an instant `scrollTop` write in a
|
|
// `useLayoutEffect` that runs once after Preact has already
|
|
// committed the new rows to the DOM, so it always sees the real
|
|
// `scrollHeight`, never a stale pre-mutation one.
|
|
// - A "load older" prepend is told apart from a normal append via
|
|
// `prependingRef` (set immediately before calling `loadMore()`) so
|
|
// the effect can choose the right response: viewport-preserving
|
|
// scrollTop compensation for a prepend, stick-to-bottom-or-count-
|
|
// unseen for an append. The old code inferred this from DOM
|
|
// mutation shape after the fact; here the caller already knows
|
|
// which one it did, so there's nothing to infer.
|
|
import { useLayoutEffect, useRef, useState, useImperativeHandle } from 'preact/hooks';
|
|
import { forwardRef } from 'preact/compat';
|
|
import { useLiveStream } from '../hooks/useLiveStream.js';
|
|
import { Row } from './Row.js';
|
|
|
|
const NEAR_BOTTOM_PX = 48;
|
|
const LOAD_MORE_SCROLL_PX = 80;
|
|
|
|
export interface LiveStreamProps {
|
|
/** Forwarded to useLiveStream — fires on live turn_start/turn_end so
|
|
* the caller can refresh `/api/state` sooner than its poll interval. */
|
|
onLiveTurnBoundary?: () => void;
|
|
}
|
|
|
|
/** Imperative escape hatch for TermInput's local-only slash commands
|
|
* (`/help`, `/clear`) — same shape as app.js's old `termAPI` object,
|
|
* kept as a ref handle rather than lifting the whole row array up to
|
|
* Root so LiveStream/useLiveStream still own their state privately. */
|
|
export interface LiveStreamHandle {
|
|
pushNote: (text: string) => void;
|
|
clear: () => void;
|
|
}
|
|
|
|
export const LiveStream = forwardRef<LiveStreamHandle, LiveStreamProps>(function LiveStream(
|
|
{ onLiveTurnBoundary },
|
|
ref,
|
|
) {
|
|
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = useLiveStream({ onLiveTurnBoundary });
|
|
useImperativeHandle(ref, () => ({ pushNote: pushLocalNote, clear: clearLocal }), [pushLocalNote, clearLocal]);
|
|
const logRef = useRef<HTMLDivElement>(null);
|
|
const [stickToBottom, setStickToBottom] = useState(true);
|
|
const [unseen, setUnseen] = useState(0);
|
|
const prevRowCount = useRef(0);
|
|
const prependingRef = useRef(false);
|
|
const preScrollHeightRef = useRef(0);
|
|
|
|
function isNearBottom(el: HTMLDivElement): boolean {
|
|
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX;
|
|
}
|
|
|
|
function handleScroll() {
|
|
const el = logRef.current;
|
|
if (!el) return;
|
|
const nearBottom = isNearBottom(el);
|
|
setStickToBottom(nearBottom);
|
|
if (nearBottom) setUnseen(0);
|
|
if (el.scrollTop <= LOAD_MORE_SCROLL_PX && hasMore && !loadingMore) startLoadMore();
|
|
}
|
|
|
|
function startLoadMore() {
|
|
const el = logRef.current;
|
|
if (!el || loadingMore || !hasMore) return;
|
|
prependingRef.current = true;
|
|
preScrollHeightRef.current = el.scrollHeight;
|
|
loadMore();
|
|
}
|
|
|
|
function jumpToBottom() {
|
|
const el = logRef.current;
|
|
if (el) el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
setStickToBottom(true);
|
|
setUnseen(0);
|
|
}
|
|
|
|
// Runs after every commit that changed `rows` — i.e. after the browser
|
|
// has already laid out the new/coalesced content, so `scrollHeight`
|
|
// below is always current.
|
|
useLayoutEffect(() => {
|
|
const el = logRef.current;
|
|
if (!el) return;
|
|
const added = rows.length - prevRowCount.current;
|
|
prevRowCount.current = rows.length;
|
|
|
|
if (prependingRef.current) {
|
|
prependingRef.current = false;
|
|
el.scrollTop += el.scrollHeight - preScrollHeightRef.current;
|
|
return;
|
|
}
|
|
if (stickToBottom) {
|
|
el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
} else if (added > 0) {
|
|
setUnseen((n) => n + added);
|
|
}
|
|
}, [rows, stickToBottom]);
|
|
|
|
return (
|
|
<div className="terminal-wrap">
|
|
<div className="live terminal" ref={logRef} onScroll={handleScroll}>
|
|
{hasMore && (
|
|
<button type="button" className="load-more-pill" disabled={loadingMore} onClick={startLoadMore}>
|
|
{loadingMore ? '↑ loading…' : '↑ load older'}
|
|
</button>
|
|
)}
|
|
{rows.map((row) => (
|
|
<Row key={row.key} row={row} />
|
|
))}
|
|
</div>
|
|
{unseen > 0 && (
|
|
<button type="button" className="tail-pill visible" onClick={jumpToBottom}>
|
|
↓ {unseen} new
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|