Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions src/components/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -661,13 +682,37 @@ 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;
el.scrollTo({ top: el.scrollHeight });
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<number | null>(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(() => {
Expand Down Expand Up @@ -807,9 +852,19 @@ export function ChatView({ bot }: { bot: Bot }) {
aria-live="polite"
aria-label={`Conversation with ${bot.name}`}
>
{hiddenCount > 0 && (
<div className="flex justify-center pt-2">
<button
onClick={showEarlier}
className="rounded-full border border-hairline/40 bg-panel px-3 py-1 text-[12.5px] text-ink-secondary hover:bg-raised hover:text-ink"
>
Show earlier messages ({hiddenCount} more)
</button>
</div>
)}
<MessagesList
bot={bot}
messages={messages}
messages={windowedMessages}
editingId={editingId}
lastBotTextId={lastBotTextId}
canRetryLast={!bot.busy && Boolean(lastUserMessage)}
Expand Down
65 changes: 62 additions & 3 deletions src/components/GroupView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// carry the personality; avatars inside the room stay still so a busy group
// does not become a wall of competing motion. Plain messages go to the room's
// default responder; @mentions override that routing.
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { ArrowDown, ChevronDown, Pin } from "lucide-react";
import {
useStore,
Expand All @@ -11,6 +11,7 @@ import {
type Bot,
type Group,
type GroupDefaultResponder,
type Message,
} from "@/state/store";
import { MausAvatar } from "./Avatar";
import { normalizeState } from "@/lib/mascot";
Expand All @@ -23,6 +24,7 @@ import { ApprovalCard } from "./ApprovalCard";
import { cn } from "@/lib/cn";
import { BOTTOM_FOLLOW_THRESHOLD, shouldResumeBottomFollow } from "@/lib/bottom-follow";
import { showWorkingDots } from "@/lib/turn-tail";
import { expandWindowStart, resolveTranscriptWindow, tailWindowStart } from "@/lib/transcript-window";

function dayLabel(at: number): string {
const d = new Date(at);
Expand Down Expand Up @@ -54,12 +56,15 @@ function ClusterLabel({ bot, name, color }: { bot?: Bot; name: string; color: st
const Transcript = memo(function Transcript({
group,
members,
messages,
}: {
group: Group;
members: Bot[];
/** The windowed suffix of group.messages — the boundary lives in GroupView. */
messages: Message[];
}) {
const memberOf = (id?: string) => members.find((b) => b.id === id);
const textMessages = group.messages;
const textMessages = messages;
return (
<>
{textMessages.map((m, i) => {
Expand Down Expand Up @@ -207,20 +212,64 @@ 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);
}, []);

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;
el.scrollTo({ top: el.scrollHeight });
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<number | null>(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;
Expand Down Expand Up @@ -367,7 +416,17 @@ export function GroupView({ group }: { group: Group }) {
</div>
</div>
)}
<Transcript group={group} members={members} />
{hiddenCount > 0 && (
<div className="flex justify-center pt-2">
<button
onClick={showEarlier}
className="rounded-full border border-hairline/40 bg-panel px-3 py-1 text-[12.5px] text-ink-secondary hover:bg-raised hover:text-ink"
>
Show earlier messages ({hiddenCount} more)
</button>
</div>
)}
<Transcript group={group} members={members} messages={windowedMessages} />
{speaker && showWorkingDots(true, streaming, group.messages.at(-1), speaker.id) && (
<>
<ClusterLabel bot={speaker} name={speaker.name} color={speaker.color} />
Expand Down
104 changes: 104 additions & 0 deletions src/lib/transcript-window.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
40 changes: 40 additions & 0 deletions src/lib/transcript-window.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
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<T>(
messages: readonly T[],
startIndex: number,
size: number = TRANSCRIPT_WINDOW_SIZE,
): TranscriptWindow<T> {
const start =
startIndex >= messages.length ? tailWindowStart(messages.length, size) : Math.max(0, startIndex);
return { visible: messages.slice(start), hiddenCount: start, startIndex: start };
}
Loading