All files / src/components/session SessionTimeline.tsx

0% Statements 0/107
0% Branches 0/1
0% Functions 0/1
0% Lines 0/107

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147                                                                                                                                                                                                                                                                                                     
import { useCallback, useEffect, useRef } from "react";
import { useAutoScroll } from "../../hooks/useAutoScroll";
import type { TimelineItem } from "../../types";
import { EmptyState } from "../shared/EmptyState";
import { TimelineEntry } from "./TimelineEntry";
 
const USER_MESSAGE_TOP_OFFSET = 8;
const ASSISTANT_MESSAGE_TOP_OFFSET = 8;
 
/**
 * Wait for all images inside `container` to finish loading so that
 * offsetTop calculations account for the final layout.  Resolves
 * immediately when there are no pending images.
 */
function waitForImages(container: HTMLElement): Promise<void> {
  const imgs = container.querySelectorAll<HTMLImageElement>("img");
  const pending = Array.from(imgs).filter((img) => !img.complete);
  if (pending.length === 0) return Promise.resolve();
  return Promise.all(
    pending.map(
      (img) =>
        new Promise<void>((resolve) => {
          img.addEventListener("load", () => resolve(), { once: true });
          img.addEventListener("error", () => resolve(), { once: true });
        }),
    ),
  ).then(() => {});
}
 
export function SessionTimeline({
  items,
  subAgents,
  connection,
  waitMessage,
  itemsVersion,
}: {
  items: TimelineItem[];
  subAgents: Record<string, { title: string; status: string }>;
  connection: "disconnected" | "connecting" | "connected";
  waitMessage: string | null;
  itemsVersion: number;
}) {
  const previousLengthRef = useRef<number | undefined>(undefined);
  const latestItem = items.at(-1);
  const latestItemIsUserMessage =
    latestItem?.kind === "message" && latestItem.role === "user";
  const { containerRef, showNewMessages, setShowNewMessages, scrollToBottom, userScrolledRef } =
    useAutoScroll(itemsVersion, { followOnChange: false });
 
  const scrollToTarget = useCallback(
    (container: HTMLElement, target: HTMLElement, offset: number) => {
      container.scrollTo({
        top: Math.max(target.offsetTop - offset, 0),
        behavior: "smooth",
      });
    },
    [],
  );
 
  useEffect(() => {
    const previousLength = previousLengthRef.current;
    previousLengthRef.current = items.length;
 
    const container = containerRef.current;
    if (!container) return;
 
    const isNewItem =
      previousLength !== undefined && items.length > previousLength;
 
    if (isNewItem && latestItem) {
      // New item added — scroll to the top of that item
      const target = container.querySelector<HTMLElement>(
        `[data-timeline-item-id="${CSS.escape(latestItem.itemId)}"]`,
      );
 
      if (latestItemIsUserMessage) {
        // Always scroll to user messages
        if (target) {
          void waitForImages(container).then(() => {
            scrollToTarget(container, target, USER_MESSAGE_TOP_OFFSET);
          });
        }
        userScrolledRef.current = false;
      } else if (!userScrolledRef.current) {
        // Scroll to top of new assistant/tool/thinking item
        if (target) {
          void waitForImages(container).then(() => {
            scrollToTarget(container, target, ASSISTANT_MESSAGE_TOP_OFFSET);
          });
        }
      } else {
        setShowNewMessages(true);
      }
    } else if (!isNewItem) {
      // Existing item content updated — stick to bottom
      if (!userScrolledRef.current) {
        container.scrollTo({ top: container.scrollHeight, behavior: "instant" });
      } else {
        setShowNewMessages(true);
      }
    }
  }, [containerRef, itemsVersion, items.length, latestItem, latestItemIsUserMessage, userScrolledRef, setShowNewMessages, scrollToTarget]);
 
  if (items.length === 0 && connection === "connected") {
    return (
      <div className="session-scroll-area" ref={containerRef}>
        <div className="timeline">
          <EmptyState
            title="No messages yet"
            description="Send a message to start the conversation"
          />
        </div>
      </div>
    );
  }
 
  return (
    <div className="session-scroll-area" ref={containerRef}>
      <div className="timeline">
        {items.map((item) => (
          <TimelineEntry
            key={item.itemId}
            item={item}
            subAgentTitle={item.subAgentId ? subAgents[item.subAgentId]?.title : undefined}
            subAgentStatus={item.subAgentId ? subAgents[item.subAgentId]?.status : undefined}
          />
        ))}
        {waitMessage ? (
          <div className="processing-indicator">
            <div className="spinner spinner--sm" />
            <span>{waitMessage}</span>
          </div>
        ) : null}
        {showNewMessages ? (
          <button
            type="button"
            className="timeline__new-messages"
            onClick={scrollToBottom}
          >
            New messages below
          </button>
        ) : null}
      </div>
    </div>
  );
}