diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index d86dc9a17..fd781cf03 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -1,4 +1,4 @@ -import { Component, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { Component, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { AlertTriangle, ArrowDown, @@ -45,6 +45,7 @@ import { CallButton, CallOverlay } from "./CallView"; import { cn } from "@/lib/cn"; import { webhookMessageView } from "@/lib/webhook-message"; import { BOTTOM_FOLLOW_THRESHOLD, shouldResumeBottomFollow } from "@/lib/bottom-follow"; +import { expandWindowStart, resolveTranscriptWindow, tailWindowStart } from "@/lib/transcript-window"; /** Long user messages collapse behind a fade so pasted walls of text don't * bury the conversation; bots get full markdown. */ @@ -615,6 +616,26 @@ export function ChatView({ bot }: { bot: Bot }) { // only the active branch is rendered; forks stay reachable via ‹ › nav const messages = useMemo(() => visibleMessages(bot), [bot]); + + // Windowed transcript: only a tail of the thread mounts (screenshots make + // full threads DOM-heavy). The boundary is anchored per bot+task; a + // render-phase reset re-tails it on switch so the old thread's boundary + // never flashes into the new one. Everything derived below (lastBotTextId, + // lastUserMessage, working dots) stays computed from the FULL list. + const transcriptKey = `${bot.id}:${bot.threadId}`; + const [transcriptWindow, setTranscriptWindow] = useState(() => ({ + key: transcriptKey, + start: tailWindowStart(messages.length), + })); + if (transcriptWindow.key !== transcriptKey) { + setTranscriptWindow({ key: transcriptKey, start: tailWindowStart(messages.length) }); + } + const { + visible: windowedMessages, + hiddenCount, + startIndex, + } = useMemo(() => resolveTranscriptWindow(messages, transcriptWindow.start), [messages, transcriptWindow.start]); + const lastBotTextId = useMemo( () => [...messages].reverse().find((m) => m.role === "bot" && m.kind === "text")?.id, [messages], @@ -661,6 +682,8 @@ export function ChatView({ bot }: { bot: Bot }) { }, []); useEffect(() => setBottomFollow(true), [bot.id, setBottomFollow]); + // deps track the FULL messages.length, so expanding the window (which only + // changes windowedMessages) can never re-trigger this bottom scrollTo useEffect(() => { const el = scrollRef.current; if (!el || !followRef.current) return; @@ -668,6 +691,28 @@ export function ChatView({ bot }: { bot: Bot }) { previousScrollTop.current = el.scrollTop; }, [bot.id, messages.length, streaming, reasoning, bot.busy, follow]); + // Expanding prepends rows: capture the height first, then after the commit + // shift scrollTop by the growth so the message under the cursor stays put + // (browser scroll anchoring is disabled on this container). + const preExpandHeight = useRef(null); + const showEarlier = () => { + preExpandHeight.current = scrollRef.current?.scrollHeight ?? null; + // expanding means reading scrollback — never let a mid-expand stream + // event pin the viewport back to the bottom + setBottomFollow(false); + const start = expandWindowStart(startIndex); + setTranscriptWindow((w) => ({ key: w.key, start })); + }; + useLayoutEffect(() => { + const el = scrollRef.current; + if (preExpandHeight.current === null || !el) return; + el.scrollTop += el.scrollHeight - preExpandHeight.current; + preExpandHeight.current = null; + // keep the resume-follow heuristic from reading the restore as a + // downward user scroll + previousScrollTop.current = el.scrollTop; + }, [transcriptWindow.start]); + // keyboard is a scroll gesture too (upstream lesson): PageUp/Home break // follow like an upward wheel; the at-end onScroll check re-arms it useEffect(() => { @@ -807,9 +852,19 @@ export function ChatView({ bot }: { bot: Bot }) { aria-live="polite" aria-label={`Conversation with ${bot.name}`} > + {hiddenCount > 0 && ( +
+ +
+ )} members.find((b) => b.id === id); - const textMessages = group.messages; + const textMessages = messages; return ( <> {textMessages.map((m, i) => { @@ -207,6 +212,26 @@ export function GroupView({ group }: { group: Group }) { ); const speaker = members.find((b) => b.id === group.busyBotId); + // Windowed transcript, mirroring ChatView: only a tail of the room mounts; + // the anchored boundary re-tails on a render-phase reset when the room (or + // its thread) changes. Working dots below stay on the FULL list's tail. + const transcriptKey = `${group.id}:${group.threadId}`; + const [transcriptWindow, setTranscriptWindow] = useState(() => ({ + key: transcriptKey, + start: tailWindowStart(group.messages.length), + })); + if (transcriptWindow.key !== transcriptKey) { + setTranscriptWindow({ key: transcriptKey, start: tailWindowStart(group.messages.length) }); + } + const { + visible: windowedMessages, + hiddenCount, + startIndex, + } = useMemo( + () => resolveTranscriptWindow(group.messages, transcriptWindow.start), + [group.messages, transcriptWindow.start], + ); + const setBottomFollow = useCallback((next: boolean) => { followRef.current = next; setFollow(next); @@ -214,6 +239,8 @@ export function GroupView({ group }: { group: Group }) { useEffect(() => setBottomFollow(true), [group.id, setBottomFollow]); useEffect(() => setBulletinDraft(group.bulletin), [group.id, group.bulletin]); + // deps track the FULL messages.length, so expanding the window (which only + // changes windowedMessages) can never re-trigger this bottom scrollTo useEffect(() => { const el = scrollRef.current; if (!el || !followRef.current) return; @@ -221,6 +248,28 @@ export function GroupView({ group }: { group: Group }) { previousScrollTop.current = el.scrollTop; }, [group.id, group.messages.length, streaming, group.busyBotId, follow]); + // Expanding prepends rows: capture the height first, then after the commit + // shift scrollTop by the growth so the message under the cursor stays put + // (browser scroll anchoring is disabled on this container). + const preExpandHeight = useRef(null); + const showEarlier = () => { + preExpandHeight.current = scrollRef.current?.scrollHeight ?? null; + // expanding means reading scrollback — never let a mid-expand stream + // event pin the viewport back to the bottom + setBottomFollow(false); + const start = expandWindowStart(startIndex); + setTranscriptWindow((w) => ({ key: w.key, start })); + }; + useLayoutEffect(() => { + const el = scrollRef.current; + if (preExpandHeight.current === null || !el) return; + el.scrollTop += el.scrollHeight - preExpandHeight.current; + preExpandHeight.current = null; + // keep the resume-follow heuristic from reading the restore as a + // downward user scroll + previousScrollTop.current = el.scrollTop; + }, [transcriptWindow.start]); + const atEnd = () => { const el = scrollRef.current; return !el || el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_FOLLOW_THRESHOLD; @@ -367,7 +416,17 @@ export function GroupView({ group }: { group: Group }) { )} - + {hiddenCount > 0 && ( +
+ +
+ )} + {speaker && showWorkingDots(true, streaming, group.messages.at(-1), speaker.id) && ( <> diff --git a/src/lib/transcript-window.test.ts b/src/lib/transcript-window.test.ts new file mode 100644 index 000000000..2be0b3a1e --- /dev/null +++ b/src/lib/transcript-window.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { + TRANSCRIPT_WINDOW_SIZE, + expandWindowStart, + resolveTranscriptWindow, + tailWindowStart, +} from "./transcript-window"; + +const thread = (total: number): number[] => Array.from({ length: total }, (_, i) => i); + +describe("tailWindowStart", () => { + it("shows everything when the thread is shorter than the window", () => { + expect(tailWindowStart(10)).toBe(0); + }); + + it("shows everything when the thread is exactly one window", () => { + expect(tailWindowStart(TRANSCRIPT_WINDOW_SIZE)).toBe(0); + }); + + it("starts one window back from the tail of a long thread", () => { + expect(tailWindowStart(300)).toBe(180); + }); + + it("is zero for an empty thread", () => { + expect(tailWindowStart(0)).toBe(0); + }); +}); + +describe("expandWindowStart", () => { + it("pulls the boundary back by one window per click", () => { + expect(expandWindowStart(300)).toBe(180); + }); + + it("clamps an expansion past the start of the thread to zero", () => { + expect(expandWindowStart(60)).toBe(0); + }); + + it("stays at zero once fully expanded", () => { + expect(expandWindowStart(0)).toBe(0); + }); +}); + +describe("resolveTranscriptWindow", () => { + it("keeps a short thread fully visible with nothing hidden", () => { + const result = resolveTranscriptWindow(thread(10), tailWindowStart(10)); + expect(result.visible).toHaveLength(10); + expect(result.hiddenCount).toBe(0); + expect(result.startIndex).toBe(0); + }); + + it("windows a long thread to its tail", () => { + const result = resolveTranscriptWindow(thread(300), tailWindowStart(300)); + expect(result.visible).toHaveLength(TRANSCRIPT_WINDOW_SIZE); + expect(result.visible[0]).toBe(180); + expect(result.visible.at(-1)).toBe(299); + expect(result.hiddenCount).toBe(180); + }); + + it("grows the window when messages append past an anchored boundary", () => { + const start = tailWindowStart(300); + const result = resolveTranscriptWindow(thread(310), start); + // the boundary must not slide forward: rows on screen stay on screen + expect(result.startIndex).toBe(start); + expect(result.visible).toHaveLength(130); + expect(result.visible.at(-1)).toBe(309); + }); + + it("expands by one window per step until the start of the thread", () => { + const messages = thread(300); + const once = resolveTranscriptWindow(messages, expandWindowStart(180)); + expect(once.visible).toHaveLength(240); + expect(once.hiddenCount).toBe(60); + const twice = resolveTranscriptWindow(messages, expandWindowStart(once.startIndex)); + expect(twice.visible).toHaveLength(300); + expect(twice.hiddenCount).toBe(0); + }); + + it("falls back to a tail window when the thread shrinks under the boundary", () => { + // branch switch / edit rewound the thread below the stored boundary + const result = resolveTranscriptWindow(thread(150), 180); + expect(result.startIndex).toBe(30); + expect(result.visible).toHaveLength(TRANSCRIPT_WINDOW_SIZE); + expect(result.hiddenCount).toBe(30); + }); + + it("resets a boundary sitting exactly at the end of the thread", () => { + const result = resolveTranscriptWindow(thread(100), 100); + expect(result.startIndex).toBe(0); + expect(result.visible).toHaveLength(100); + }); + + it("resolves an empty thread to an empty window", () => { + const result = resolveTranscriptWindow([], 0); + expect(result.visible).toHaveLength(0); + expect(result.hiddenCount).toBe(0); + }); + + it("respects a custom window size", () => { + const result = resolveTranscriptWindow(thread(10), tailWindowStart(10, 4), 4); + expect(result.visible).toHaveLength(4); + expect(result.hiddenCount).toBe(6); + }); +}); diff --git a/src/lib/transcript-window.ts b/src/lib/transcript-window.ts new file mode 100644 index 000000000..edbc00418 --- /dev/null +++ b/src/lib/transcript-window.ts @@ -0,0 +1,40 @@ +/** Long computer-use threads carry hundreds of rows (inline screenshots + * included); mounting all of them makes the DOM heavy even though the memoized + * list bails out of re-renders. Only the last `TRANSCRIPT_WINDOW_SIZE` + * messages mount by default; a pill expands by the same step. */ +export const TRANSCRIPT_WINDOW_SIZE = 120; + +export interface TranscriptWindow { + visible: T[]; + /** Messages hidden before the window — the pill's "(X more)" count. */ + hiddenCount: number; + /** The boundary actually applied after clamping; expand steps from this, + * not from the stored value, so a clamped window expands predictably. */ + startIndex: number; +} + +/** Boundary for a fresh window: the last `size` messages. */ +export function tailWindowStart(total: number, size: number = TRANSCRIPT_WINDOW_SIZE): number { + return Math.max(0, total - size); +} + +/** One "Show earlier" click: pull the boundary back by another `size`. */ +export function expandWindowStart(startIndex: number, size: number = TRANSCRIPT_WINDOW_SIZE): number { + return Math.max(0, startIndex - size); +} + +/** Resolve a stored boundary against the current list. The boundary is + * anchored — appends grow the window instead of sliding it, so rows the + * reader is looking at never drop out from under them. Anchoring means a + * thread that shrinks (branch switch, edit rewinding the tail) can leave the + * boundary at or past the new end; that stale boundary falls back to a fresh + * tail window rather than blanking the transcript. */ +export function resolveTranscriptWindow( + messages: readonly T[], + startIndex: number, + size: number = TRANSCRIPT_WINDOW_SIZE, +): TranscriptWindow { + const start = + startIndex >= messages.length ? tailWindowStart(messages.length, size) : Math.max(0, startIndex); + return { visible: messages.slice(start), hiddenCount: start, startIndex: start }; +}