From 279b79516c982bd377f18aebeedb750de1277f95 Mon Sep 17 00:00:00 2001 From: User Date: Wed, 15 Jul 2026 11:55:14 +0200 Subject: [PATCH] fix(frontend): deduplicate live workflow activity --- .../src/shell/AppShell.new-session.test.tsx | 18 +- .../src/shell/AppShell.session-mgmt.test.tsx | 14 +- frontend/src/shell/AppShell.tsx | 11 +- frontend/src/shell/CentralStatus.test.tsx | 96 ++++++++--- frontend/src/shell/CentralStatus.tsx | 155 ++++++------------ .../src/shell/ModelActivityPanel.test.tsx | 42 ++--- frontend/src/shell/ModelActivityPanel.tsx | 10 +- frontend/src/shell/activityScroll.ts | 6 + 8 files changed, 169 insertions(+), 183 deletions(-) create mode 100644 frontend/src/shell/activityScroll.ts diff --git a/frontend/src/shell/AppShell.new-session.test.tsx b/frontend/src/shell/AppShell.new-session.test.tsx index 5ad18438..24b05d60 100644 --- a/frontend/src/shell/AppShell.new-session.test.tsx +++ b/frontend/src/shell/AppShell.new-session.test.tsx @@ -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((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"); }); diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 8d4dda81..3ad419f6 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -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); diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index ea583353..12963056 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -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() {
{sessionViewOpen ? ( <> - + {activeSessionId && } {finalized && !agentActive && (
diff --git a/frontend/src/shell/CentralStatus.test.tsx b/frontend/src/shell/CentralStatus.test.tsx index 90ba4dbe..0f84d51a 100644 --- a/frontend/src/shell/CentralStatus.test.tsx +++ b/frontend/src/shell/CentralStatus.test.tsx @@ -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" }); +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" }, + ], + }); + render(); - // 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(); + + 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("renders nothing when not working and there is no user entry or step messages", () => { - const { container } = render(); +test("wraps live log lines instead of truncating them", () => { + useSessionStore.setState({ + transcript: [{ role: "assistant", text: "a_very_long_model_activity_line" }], + }); + render(); + 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(); 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(); - 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(); - // 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(); - 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(); + 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); }); diff --git a/frontend/src/shell/CentralStatus.tsx b/frontend/src/shell/CentralStatus.tsx index e434a9c9..4fc43c3b 100644 --- a/frontend/src/shell/CentralStatus.tsx +++ b/frontend/src/shell/CentralStatus.tsx @@ -1,112 +1,65 @@ -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 - .split("\n") - .map((l) => l.trimEnd()) - .filter((l) => l.trim() !== "") - .slice(-n); +function transcriptLines(transcript: Array<{ text: string }>): string[] { + return transcript.flatMap(({ text }) => + text + .split("\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(null); + const followRef = useRef(true); - const [startedAt, setStartedAt] = useState(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 || rows.length === 0) return null; - 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 ?? ""); - return ( -
- {lastUserEntry && ( -
- - {lastUserEntry.kind === "input" ? "You asked" : "You chose"} - -

{lastUserEntry.text}

-
- )} - - {working && ( -
-
- {/* Pure status indicator — opening the activity panel now lives on the - dedicated arrow toggle in the header, not on this spinner. */} - - +
    { + followRef.current = isNearBottom(event.currentTarget); + }} + > + {rows.map((line, index) => { + const isLast = index === rows.length - 1; + return ( +
  1. + + › - {elapsed}s - {workingLabel ?? "ThothII is working"} -
- {tailRows.length > 0 && ( -
    - {tailRows.map((line, i) => { - const isLast = i === tailRows.length - 1; - return ( -
  1. - - › - - {line} -
  2. - ); - })} -
- )} -
- )} - - {stepMessages.map((m, i) => ( -

- {m.text} -

- ))} -
+ {line} + + ); + })} + ); } diff --git a/frontend/src/shell/ModelActivityPanel.test.tsx b/frontend/src/shell/ModelActivityPanel.test.tsx index 80d27742..8bf9b55c 100644 --- a/frontend/src/shell/ModelActivityPanel.test.tsx +++ b/frontend/src/shell/ModelActivityPanel.test.tsx @@ -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(); - 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(); @@ -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(); 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(); 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(); 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); }); diff --git a/frontend/src/shell/ModelActivityPanel.tsx b/frontend/src/shell/ModelActivityPanel.tsx index ff1f38a0..1d4ccf0c 100644 --- a/frontend/src/shell/ModelActivityPanel.tsx +++ b/frontend/src/shell/ModelActivityPanel.tsx @@ -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 = 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, - threshold = 48, -): boolean { - return el.scrollHeight - el.clientHeight - el.scrollTop <= threshold; -} - export function formatModelActivity(text: string): string { return text .replace(/\r\n?/g, "\n") diff --git a/frontend/src/shell/activityScroll.ts b/frontend/src/shell/activityScroll.ts new file mode 100644 index 00000000..423455ac --- /dev/null +++ b/frontend/src/shell/activityScroll.ts @@ -0,0 +1,6 @@ +export function isNearBottom( + el: Pick, + threshold = 48, +): boolean { + return el.scrollHeight - el.clientHeight - el.scrollTop <= threshold; +}