fix(frontend): deduplicate live workflow activity

This commit is contained in:
User
2026-07-15 11:55:14 +02:00
parent e7a8bc29b0
commit 279b79516c
8 changed files with 169 additions and 183 deletions
@@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse, delay } from "msw";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -45,7 +45,7 @@ test("New session starts prewarm without delaying composer focus", async () => {
expect(composer).toHaveClass("thot-awaiting-input");
});
test("shows the question and creation timer before POST /sessions resolves, then opens SSE", async () => {
test("records the prompt without central duplication, then opens the live log", async () => {
let releaseCreate!: () => void;
const createMayFinish = new Promise<void>((resolve) => { releaseCreate = resolve; });
server.use(
@@ -60,20 +60,20 @@ test("shows the question and creation timer before POST /sessions resolves, then
await userEvent.type(composer, "How many patients?");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
expect(await screen.findByText("How many patients?", { selector: "p" })).toBeInTheDocument();
expect(screen.getByText("Creating session…")).toBeInTheDocument();
expect(screen.getByText(/\d+s/)).toBeInTheDocument();
expect(screen.queryByText("How many patients?", { selector: "p" })).not.toBeInTheDocument();
expect(screen.queryByText("Creating session…")).not.toBeInTheDocument();
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument();
expect(useSessionStore.getState().activityLog[0]).toEqual({
kind: "prompt",
phase: "F1",
text: "How many patients?",
});
expect(FakeEventSource.instances).toHaveLength(0);
releaseCreate();
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
expect(screen.getByText("How many patients?", { selector: "p" })).toBeInTheDocument();
expect(screen.getByText("Analyzing question…")).toBeInTheDocument();
act(() => FakeEventSource.instances[0].emit({ type: "text_delta", text: "Inspecting cohort" }));
expect(await screen.findByRole("log", { name: "Live model activity" }))
.toHaveTextContent("Inspecting cohort");
expect(screen.queryByText("Analyzing question…")).not.toBeInTheDocument();
expect(useSessionStore.getState().currentPhase).toBe("F1");
});
@@ -697,15 +697,13 @@ test("closing and reopening Model activity preserves the complete activity log",
const store = useSessionStore.getState();
store.setPhase("F4");
store.setLastUserEntry({ kind: "input", text: "Inspect patient cohort" });
store.applyEvent({
type: "activity_event",
activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "running" },
});
store.applyEvent({
type: "activity_event",
activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "completed" },
});
store.applyEvent({ type: "text_delta", text: "Cohort ready" });
store.applyEvent({ type: "activity_delta", text: "Inspecting **cohort**" });
store.applyEvent({ type: "info", level: "info", text: "Cohort status" });
});
const beforeClose = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
@@ -713,7 +711,9 @@ test("closing and reopening Model activity preserves the complete activity log",
await userEvent.click(screen.getByRole("button", { name: /model activity/i }));
const panel = (await screen.findByRole("heading", { name: /model activity/i })).closest("aside");
expect(panel).not.toBeNull();
expect(within(panel!).getByText("Inspect patient cohort")).toBeInTheDocument();
expect(within(panel!).getByText("cohort").tagName).toBe("STRONG");
expect(within(panel!).getByText("Cohort status")).toBeInTheDocument();
expect(within(panel!).queryByText("Inspect patient cohort")).not.toBeInTheDocument();
expect(within(panel!).queryByText("bash")).not.toBeInTheDocument();
expect(within(panel!).queryByText("Cohort ready")).not.toBeInTheDocument();
@@ -724,7 +724,9 @@ test("closing and reopening Model activity preserves the complete activity log",
await userEvent.click(screen.getByRole("button", { name: /show model activity/i }));
const reopened = (await screen.findByRole("heading", { name: /model activity/i })).closest("aside");
expect(reopened).not.toBeNull();
expect(within(reopened!).getByText("Inspect patient cohort")).toBeInTheDocument();
expect(within(reopened!).getByText("cohort").tagName).toBe("STRONG");
expect(within(reopened!).getByText("Cohort status")).toBeInTheDocument();
expect(within(reopened!).queryByText("Inspect patient cohort")).not.toBeInTheDocument();
expect(within(reopened!).queryByText("bash")).not.toBeInTheDocument();
expect(within(reopened!).queryByText("Cohort ready")).not.toBeInTheDocument();
expect(useSessionStore.getState().activityLog).toEqual(beforeClose);
+4 -7
View File
@@ -279,7 +279,7 @@ export function AppShell() {
// The harness "holds the ball" whenever a session is live, no widget is waiting on
// the human, AND the Pi turn is still in flight (agentActive). Without the last
// condition the final workflow step — which ends with no follow-up gate — would
// leave the spinner on forever.
// leave the working state on forever.
const pendingWidget = useSessionStore((s) => s.pendingWidget);
const resetSession = useSessionStore((s) => s.resetSession);
const recordLifecycle = useSessionStore((s) => s.recordLifecycle);
@@ -289,7 +289,7 @@ export function AppShell() {
const agentActive = useSessionStore((s) => s.agentActive);
const sessionViewOpen = Boolean(activeSessionId) || creatingSession;
const working = sessionViewOpen && !pendingWidget && agentActive;
// Processing time counts only while the harness works, not while a finalized
// The workflow bar runs only while the harness works, not while a finalized
// session sits idle or a gate awaits the reviewer (pendingWidget).
const running = working && !finalized;
@@ -336,7 +336,7 @@ export function AppShell() {
}
function finishSessionCreation(id: string) {
// React batches these updates, preserving the provisional view and timer
// React batches these updates, preserving the provisional session view
// while useSessionStream opens the durable session's SSE channel.
selectActiveSession(id);
setCreatingSession(false);
@@ -406,10 +406,7 @@ export function AppShell() {
<div className="mx-auto flex max-w-4xl flex-col gap-6">
{sessionViewOpen ? (
<>
<CentralStatus
working={working}
workingLabel={creatingSession ? "Creating session…" : "Analyzing question…"}
/>
<CentralStatus working={working} />
{activeSessionId && <WidgetHost sessionId={activeSessionId} />}
{finalized && !agentActive && (
<div className="rounded-2xl border border-border/80 bg-card p-5 text-center shadow-md">
+70 -28
View File
@@ -1,43 +1,85 @@
import { render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach } from "vitest";
import { useSessionStore } from "../store/sessionStore";
import { CentralStatus } from "./CentralStatus";
beforeEach(() => useSessionStore.getState().resetSession());
test("the working spinner is a pure status indicator (no activity toggle on it)", () => {
useSessionStore.getState().applyEvent({ type: "text_delta", text: "reading the schema" });
render(<CentralStatus working />);
// The spinner no longer opens the panel — that moved to the header arrow toggle.
expect(screen.queryByRole("button", { name: /model activity/i })).not.toBeInTheDocument();
expect(screen.getByRole("status")).toBeInTheDocument();
function setScrollGeometry(
element: HTMLElement,
values: { scrollHeight: number; clientHeight: number; scrollTop: number },
) {
Object.defineProperties(element, {
scrollHeight: { configurable: true, value: values.scrollHeight },
clientHeight: { configurable: true, value: values.clientHeight },
scrollTop: { configurable: true, writable: true, value: values.scrollTop },
});
}
test("renders only the chronological assistant transcript log while working", () => {
useSessionStore.setState({
lastUserEntry: { kind: "input", text: "how many patients?" },
stepMessages: [{ level: "warning", text: "Searching the schema…" }],
transcript: [
{ role: "assistant", text: "First line\n\nSecond line" },
{ role: "assistant", text: "Third line" },
],
});
test("renders nothing when not working and there is no user entry or step messages", () => {
const { container } = render(<CentralStatus working={false} />);
render(<CentralStatus working />);
const log = screen.getByRole("log", { name: "Live model activity" });
expect(log).toHaveTextContent("First line");
expect(log).toHaveTextContent("Second line");
expect(log).toHaveTextContent("Third line");
expect(log).toHaveClass("max-h-28", "overflow-y-auto");
expect(screen.queryByText("how many patients?")).not.toBeInTheDocument();
expect(screen.queryByText("Searching the schema…")).not.toBeInTheDocument();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument();
});
test("wraps live log lines instead of truncating them", () => {
useSessionStore.setState({
transcript: [{ role: "assistant", text: "a_very_long_model_activity_line" }],
});
render(<CentralStatus working />);
expect(screen.getByText("a_very_long_model_activity_line")).toHaveClass(
"whitespace-pre-wrap",
"break-words",
);
expect(screen.getByText("a_very_long_model_activity_line")).not.toHaveClass("truncate");
});
test.each([
{ working: false, transcript: [{ role: "assistant" as const, text: "hidden" }] },
{ working: true, transcript: [] },
{ working: true, transcript: [{ role: "assistant" as const, text: " \n " }] },
])("renders nothing without active non-blank transcript content", ({ working, transcript }) => {
useSessionStore.setState({ transcript });
const { container } = render(<CentralStatus working={working} />);
expect(container).toBeEmptyDOMElement();
});
test("echoes the last user entry and the step messages", () => {
const st = useSessionStore.getState();
st.setLastUserEntry({ kind: "input", text: "how many patients?" });
st.applyEvent({ type: "info", level: "info", text: "Searching the schema…" });
render(<CentralStatus working={false} />);
expect(screen.getByText("how many patients?")).toBeInTheDocument();
expect(screen.getByText("Searching the schema…")).toBeInTheDocument();
});
test("shows a working indicator with elapsed time and the model-stream tail while working", () => {
useSessionStore.getState().applyEvent({ type: "text_delta", text: "Reading the schema for dim_patient" });
test("bottom-follows appended transcript text only while near the bottom", () => {
useSessionStore.setState({ transcript: [{ role: "assistant", text: "First" }] });
render(<CentralStatus working />);
// elapsed time (e.g. "0s")
expect(screen.getByText(/\d+s/)).toBeInTheDocument();
// a short tail of the model's current output
expect(screen.getByText(/Reading the schema for dim_patient/)).toBeInTheDocument();
const log = screen.getByRole("log", { name: "Live model activity" });
setScrollGeometry(log, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 });
act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: " second" }));
expect(log.scrollTop).toBe(400);
});
test("no working indicator when not working, even with a transcript", () => {
useSessionStore.getState().applyEvent({ type: "text_delta", text: "background narration" });
render(<CentralStatus working={false} />);
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument();
test("preserves manual live-log scroll position away from the bottom", () => {
useSessionStore.setState({ transcript: [{ role: "assistant", text: "First" }] });
render(<CentralStatus working />);
const log = screen.getByRole("log", { name: "Live model activity" });
setScrollGeometry(log, { scrollHeight: 400, clientHeight: 100, scrollTop: 100 });
fireEvent.scroll(log);
act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: " second" }));
expect(log.scrollTop).toBe(100);
});
+38 -85
View File
@@ -1,76 +1,50 @@
import { useEffect, useReducer, useState } from "react";
import { useLayoutEffect, useRef } from "react";
import { useSessionStore } from "../store/sessionStore";
import { WorkingSpinner } from "./WorkingSpinner";
import { isNearBottom } from "./activityScroll";
/** Last few non-blank lines of a string — a compact "what the model is doing now"
* tail (mirrors ModelActivityPanel's tail-line logic, at a smaller size). Returned
* as an array so each line renders as its own formatted log row. */
function tailLines(s: string, n = 5): string[] {
return s
function transcriptLines(transcript: Array<{ text: string }>): string[] {
return transcript.flatMap(({ text }) =>
text
.split("\n")
.map((l) => l.trimEnd())
.filter((l) => l.trim() !== "")
.slice(-n);
.map((line) => line.trimEnd())
.filter((line) => line.trim() !== ""),
);
}
/** The minimal central view: the user's last input/choice, a live "working" indicator
* (elapsed time + a short tail of the model stream) while the model is busy, and the
* gate's curated messages for the current step. The full verbose stream lives in the
* left Model-activity panel. */
export function CentralStatus({ working, workingLabel }: { working: boolean; workingLabel?: string }) {
const lastUserEntry = useSessionStore((s) => s.lastUserEntry);
const stepMessages = useSessionStore((s) => s.stepMessages);
const transcript = useSessionStore((s) => s.transcript);
/** Compact central projection of the assistant stream while the model is working. */
export function CentralStatus({ working }: { working: boolean }) {
const transcript = useSessionStore((state) => state.transcript);
const rows = transcriptLines(transcript);
const rowCount = rows.length;
const tail = rows.at(-1);
const scrollRef = useRef<HTMLOListElement>(null);
const followRef = useRef(true);
const [startedAt, setStartedAt] = useState<number | null>(null);
const [, tick] = useReducer((x: number) => x + 1, 0);
useLayoutEffect(() => {
const viewport = scrollRef.current;
if (viewport && followRef.current) viewport.scrollTop = viewport.scrollHeight;
}, [rowCount, tail]);
// Start the per-step timer when work begins; tick once a second so the elapsed
// display updates; clear it when work stops.
useEffect(() => {
if (!working) {
setStartedAt(null);
return;
}
setStartedAt((prev) => prev ?? Date.now());
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [working]);
if (!working && !lastUserEntry && stepMessages.length === 0) return null;
const elapsed = startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0;
const tailRows = tailLines(transcript.at(-1)?.text ?? "");
if (!working || rows.length === 0) return null;
return (
<div className="flex flex-col gap-3">
{lastUserEntry && (
<div className="rounded-lg border border-border/70 bg-card px-3 py-2">
<span className="thot-label">
{lastUserEntry.kind === "input" ? "You asked" : "You chose"}
</span>
<p className="mt-0.5 text-sm text-foreground">{lastUserEntry.text}</p>
</div>
)}
{working && (
<div className="flex flex-col gap-1.5">
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
{/* Pure status indicator — opening the activity panel now lives on the
dedicated arrow toggle in the header, not on this spinner. */}
<span className="grid size-5 shrink-0 place-items-center text-primary">
<WorkingSpinner spinning className="size-3.5" />
</span>
<span className="shrink-0 font-mono tabular-nums">{elapsed}s</span>
<span>{workingLabel ?? "ThothII is working"}</span>
</div>
{tailRows.length > 0 && (
<ol className="ml-7 flex flex-col gap-0.5 overflow-hidden rounded-lg border border-border/60 bg-muted/40 px-3 py-2">
{tailRows.map((line, i) => {
const isLast = i === tailRows.length - 1;
<ol
ref={scrollRef}
role="log"
aria-label="Live model activity"
aria-live="polite"
aria-relevant="additions text"
tabIndex={0}
className="max-h-28 overflow-y-auto rounded-lg border border-border/60 bg-muted/40 px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40"
onScroll={(event) => {
followRef.current = isNearBottom(event.currentTarget);
}}
>
{rows.map((line, index) => {
const isLast = index === rows.length - 1;
return (
<li
key={i}
key={index}
className={[
"flex items-baseline gap-2 font-mono text-[0.72rem] leading-relaxed",
isLast ? "text-foreground/80" : "text-muted-foreground/55",
@@ -78,35 +52,14 @@ export function CentralStatus({ working, workingLabel }: { working: boolean; wor
>
<span
aria-hidden
className={[
"shrink-0 select-none",
isLast ? "text-primary" : "text-muted-foreground/35",
].join(" ")}
className={isLast ? "shrink-0 select-none text-primary" : "shrink-0 select-none text-muted-foreground/35"}
>
›
</span>
<span className="min-w-0 flex-1 truncate">{line}</span>
<span className="min-w-0 flex-1 whitespace-pre-wrap break-words">{line}</span>
</li>
);
})}
</ol>
)}
</div>
)}
{stepMessages.map((m, i) => (
<p
key={i}
className={[
"text-sm",
m.level === "error" ? "text-destructive"
: m.level === "warning" ? "text-amber-600"
: "text-foreground/90",
].join(" ")}
>
{m.text}
</p>
))}
</div>
);
}
+18 -24
View File
@@ -3,12 +3,13 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, vi } from "vitest";
import type { ActivityEntry, ActivityKind } from "../api/types";
import { useSessionStore } from "../store/sessionStore";
import { isNearBottom } from "./activityScroll";
import * as activityPanelModule from "./ModelActivityPanel";
import { isNearBottom, ModelActivityPanel } from "./ModelActivityPanel";
import { ModelActivityPanel } from "./ModelActivityPanel";
beforeEach(() => useSessionStore.getState().resetSession());
test("renders only prompt, thinking, status, and gate from a mixed F1 sequence", () => {
test("renders only thinking and status from a mixed F1 sequence", () => {
const store = useSessionStore.getState();
store.setPhase("F1");
store.setLastUserEntry({ kind: "input", text: "How many patients?" });
@@ -17,10 +18,6 @@ test("renders only prompt, thinking, status, and gate from a mixed F1 sequence",
type: "activity_event",
activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "running" },
});
store.applyEvent({
type: "activity_event",
activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "completed" },
});
store.applyEvent({ type: "text_delta", text: "Let me run another bash search." });
store.applyEvent({ type: "activity_delta", text: "Inspecting **schema**." });
store.applyEvent({ type: "info", level: "warning", text: "Retrying schema lookup" });
@@ -31,14 +28,12 @@ test("renders only prompt, thinking, status, and gate from a mixed F1 sequence",
render(<ModelActivityPanel onClose={vi.fn()} />);
expect(screen.getAllByRole("article")).toHaveLength(4);
expect(screen.getByText("How many patients?")).toBeInTheDocument();
expect(screen.getAllByRole("article")).toHaveLength(2);
expect(screen.getByText("schema").tagName).toBe("STRONG");
expect(screen.getByText("Retrying schema lookup")).toBeInTheDocument();
expect(screen.getByText("Confirm cohort")).toBeInTheDocument();
expect(screen.queryByText("Agent started")).not.toBeInTheDocument();
expect(screen.queryByText("How many patients?")).not.toBeInTheDocument();
expect(screen.queryByText("Confirm cohort")).not.toBeInTheDocument();
expect(screen.queryByText("bash")).not.toBeInTheDocument();
expect(screen.queryByText("completed")).not.toBeInTheDocument();
expect(screen.queryByText("Let me run another bash search.")).not.toBeInTheDocument();
expect(useSessionStore.getState().transcript).toEqual([
{ role: "assistant", text: "Let me run another bash search." },
@@ -52,10 +47,10 @@ test("uses an explicit default-deny activity-kind allowlist", () => {
expect(predicate).toBeTypeOf("function");
if (!predicate) return;
for (const kind of ["prompt", "thinking", "status", "gate"] satisfies ActivityKind[]) {
for (const kind of ["thinking", "status"] satisfies ActivityKind[]) {
expect(predicate({ kind, phase: "F1", text: kind })).toBe(true);
}
for (const kind of ["assistant", "tool", "lifecycle"] satisfies ActivityKind[]) {
for (const kind of ["prompt", "gate", "assistant", "tool", "lifecycle"] satisfies ActivityKind[]) {
expect(predicate({ kind, phase: "F1", text: kind })).toBe(false);
}
expect(predicate({ kind: "future-kind" as ActivityKind, phase: "F1", text: "future" })).toBe(false);
@@ -64,6 +59,8 @@ test("uses an explicit default-deny activity-kind allowlist", () => {
test("shows the empty state when the raw log contains only hidden entries", () => {
useSessionStore.setState({
activityLog: [
{ kind: "prompt", phase: "F1", text: "How many patients?" },
{ kind: "gate", phase: "F1", text: "Confirm cohort" },
{ kind: "assistant", phase: "F1", text: "Let me try another command." },
{ kind: "tool", phase: "F1", text: "bash", toolCallId: "tool-1", status: "completed" },
{ kind: "lifecycle", phase: "F1", text: "Turn end" },
@@ -122,7 +119,7 @@ test("renders thinking markdown without assistant narration", () => {
test("close calls onClose without mutating the activity log", async () => {
const onClose = vi.fn();
const activityLog: ActivityEntry[] = [
{ kind: "prompt", phase: "F1", text: "Keep this row" },
{ kind: "status", phase: "F1", text: "Keep this row" },
];
useSessionStore.setState({ activityLog });
render(<ModelActivityPanel onClose={onClose} />);
@@ -151,9 +148,9 @@ function setScrollGeometry(
});
}
test("follows appended activity while the viewport is near the bottom", () => {
test("follows appended visible activity while near the bottom", () => {
useSessionStore.setState({
activityLog: [{ kind: "prompt", phase: "F1", text: "First" }],
activityLog: [{ kind: "thinking", phase: "F1", text: "First" }],
});
render(<ModelActivityPanel onClose={vi.fn()} />);
const viewport = screen.getByTestId("activity-scroll");
@@ -165,9 +162,9 @@ test("follows appended activity while the viewport is near the bottom", () => {
expect(viewport).toHaveAttribute("tabindex", "0");
});
test("preserves manual scroll position when appended activity arrives", () => {
test("preserves manual scroll position when visible activity arrives", () => {
useSessionStore.setState({
activityLog: [{ kind: "prompt", phase: "F1", text: "First" }],
activityLog: [{ kind: "thinking", phase: "F1", text: "First" }],
});
render(<ModelActivityPanel onClose={vi.fn()} />);
const viewport = screen.getByTestId("activity-scroll");
@@ -179,18 +176,15 @@ test("preserves manual scroll position when appended activity arrives", () => {
expect(viewport.scrollTop).toBe(100);
});
test("does not bottom-follow when only a hidden event arrives", () => {
test("does not bottom-follow when only a hidden prompt arrives", () => {
useSessionStore.setState({
activityLog: [{ kind: "prompt", phase: "F1", text: "Visible prompt" }],
activityLog: [{ kind: "status", phase: "F1", text: "Visible status" }],
});
render(<ModelActivityPanel onClose={vi.fn()} />);
const viewport = screen.getByTestId("activity-scroll");
setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 });
act(() => useSessionStore.getState().applyEvent({
type: "activity_event",
activity: { kind: "tool", toolCallId: "tool-2", toolName: "bash", status: "running" },
}));
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "Hidden prompt" }));
expect(viewport.scrollTop).toBe(300);
});
+1 -9
View File
@@ -5,25 +5,17 @@ import remarkGfm from "remark-gfm";
import type { ActivityEntry, ActivityKind } from "../api/types";
import { useSessionStore } from "../store/sessionStore";
import { Button } from "../components/ui/button";
import { isNearBottom } from "./activityScroll";
const VISIBLE_MODEL_ACTIVITY_KINDS: ReadonlySet<ActivityKind> = new Set([
"prompt",
"thinking",
"status",
"gate",
]);
export function isVisibleModelActivity(entry: ActivityEntry): boolean {
return VISIBLE_MODEL_ACTIVITY_KINDS.has(entry.kind);
}
export function isNearBottom(
el: Pick<HTMLElement, "scrollHeight" | "clientHeight" | "scrollTop">,
threshold = 48,
): boolean {
return el.scrollHeight - el.clientHeight - el.scrollTop <= threshold;
}
export function formatModelActivity(text: string): string {
return text
.replace(/\r\n?/g, "\n")
+6
View File
@@ -0,0 +1,6 @@
export function isNearBottom(
el: Pick<HTMLElement, "scrollHeight" | "clientHeight" | "scrollTop">,
threshold = 48,
): boolean {
return el.scrollHeight - el.clientHeight - el.scrollTop <= threshold;
}