Z ZPI 文档 v0.3.8
介绍页

源码

packages/ui/src/components/ConversationHistory.tsx

tsx527 行查看原文
// ZCode's timeline keeps stable height estimates and a mounted live tail (Apache-2.0).
// Normal-flow placeholders keep ZPI's existing history and run navigation anchors usable.
import {
  memo,
  type ReactNode,
  type RefObject,
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { conversationRunPartition } from "../conversation-run-partition.ts";
import type { RunView } from "../types.ts";

export const historyWindowThreshold = 40;
const estimatedRunHeight = 320;
const retainedTailRuns = 4;

type SavedNodeState = { path: number[]; tag: string; open?: boolean; top?: number; left?: number };
type SavedStates = Map<string, Map<string, SavedNodeState>>;

type Register = (id: string, node: HTMLDivElement | null, measure: () => void) => void;
interface HistoryProps {
  runs: RunView[];
  root: RefObject<HTMLDivElement | null>;
  windowed: boolean;
  initialRunId?: string;
  pinnedRunIds?: ReadonlySet<string>;
  renderRun(run: RunView): ReactNode;
}
interface CachedHistoryProps extends HistoryProps {
  retainedStates: SavedStates;
  measuredHeights: Map<string, number>;
  transitionRunIds?: ReadonlySet<string>;
}

function hasHistoryInteraction(node: HTMLElement, selection = node.ownerDocument.getSelection()): boolean {
  if (node.contains(node.ownerDocument.activeElement) || node.querySelector(".user-message-editor"))
    return true;
  if (selection && !selection.isCollapsed)
    for (let index = 0; index < selection.rangeCount; index++)
      if (selection.getRangeAt(index).intersectsNode(node)) return true;
  return false;
}

const HistoryRun = memo(function HistoryRun({
  run,
  mounted,
  register,
  renderRun,
  retainedStates,
  measuredHeights,
}: {
  run: RunView;
  mounted: boolean;
  register: Register;
  renderRun: HistoryProps["renderRun"];
  retainedStates: SavedStates;
  measuredHeights: Map<string, number>;
}) {
  const ref = useRef<HTMLDivElement | null>(null);
  const height = useRef(measuredHeights.get(run.runId) ?? estimatedRunHeight);
  let retainedState = retainedStates.get(run.runId);
  if (!retainedState) {
    retainedState = new Map<string, SavedNodeState>();
    retainedStates.set(run.runId, retainedState);
  }
  const measure = useCallback(() => {
    const node = ref.current;
    if (node?.dataset.historyMounted !== "true") return;
    const bounds = node.getBoundingClientRect();
    if (!Number.isFinite(bounds.height) || bounds.height <= 0) return;
    height.current = bounds.height;
    measuredHeights.set(run.runId, bounds.height);
  }, [measuredHeights, run.runId]);
  const bind = useCallback(
    (node: HTMLDivElement | null) => {
      ref.current = node;
      register(run.runId, node, measure);
    },
    [run.runId, register, measure],
  );
  useLayoutEffect(() => {
    if (!mounted || !ref.current) return;
    const root = ref.current;
    for (const saved of retainedState.values()) {
      let node: Element | undefined = root;
      for (const index of saved.path) node = node?.children[index];
      if (!(node instanceof HTMLElement) || node.tagName !== saved.tag) continue;
      if (node instanceof HTMLDetailsElement && saved.open !== undefined) node.open = saved.open;
      if (saved.top !== undefined) node.scrollTop = saved.top;
      if (saved.left !== undefined) node.scrollLeft = saved.left;
    }
    const retain = (event: Event) => {
      const node = event.target;
      if (!(node instanceof HTMLElement) || node === root) return;
      const path: number[] = [];
      for (let current: Element | null = node; current !== root; ) {
        const parent: Element | null = current?.parentElement ?? null;
        if (!parent || !current) return;
        path.unshift(Array.prototype.indexOf.call(parent.children, current));
        current = parent;
      }
      retainedState.set(path.join("/"), {
        path,
        tag: node.tagName,
        ...(node instanceof HTMLDetailsElement
          ? { open: node.open }
          : { top: node.scrollTop, left: node.scrollLeft }),
      });
    };
    root.addEventListener("toggle", retain, true);
    root.addEventListener("scroll", retain, true);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(ref.current);
    return () => {
      observer.disconnect();
      root.removeEventListener("toggle", retain, true);
      root.removeEventListener("scroll", retain, true);
    };
  }, [mounted, measure, retainedState]);
  return (
    <div
      ref={bind}
      data-history-run={run.runId}
      data-history-mounted={mounted}
      style={mounted ? undefined : { height: height.current }}
    >
      {mounted ? renderRun(run) : null}
    </div>
  );
});

const WindowedHistory = memo(function WindowedHistory({
  runs,
  root,
  windowed,
  initialRunId,
  pinnedRunIds,
  renderRun,
  retainedStates,
  measuredHeights,
}: CachedHistoryProps) {
  const nodes = useRef(new Map<string, { node: HTMLDivElement; measure: () => void }>());
  const observer = useRef<IntersectionObserver | null>(null);
  const intersecting = useRef(new Set<string>());
  const [visible, setVisible] = useState(() => {
    const index = runs.findIndex((run) => run.runId === initialRunId);
    return new Set(index < 0 ? [] : runs.slice(Math.max(0, index - 4), index + 5).map((run) => run.runId));
  });
  const mounted = useRef(new Set<string>());
  const wasWindowed = useRef(windowed);
  const register = useCallback<Register>((id, node, measure) => {
    const previous = nodes.current.get(id);
    if (previous) observer.current?.unobserve(previous.node);
    if (node) {
      nodes.current.set(id, { node, measure });
      observer.current?.observe(node);
    } else {
      nodes.current.delete(id);
      intersecting.current.delete(id);
    }
  }, []);
  useEffect(() => {
    // 父级滚动容器的 ref 在子组件 layout effect 之后才挂好,必须等提交完成再观察。
    const scroll = root.current;
    if (!windowed || !scroll) return;
    const update = () => {
      const next = new Set(intersecting.current);
      const selection = scroll.ownerDocument.getSelection();
      for (const id of mounted.current) {
        const node = nodes.current.get(id)?.node;
        if (!node) continue;
        if (hasHistoryInteraction(node, selection)) next.add(id);
      }
      setVisible((current) =>
        current.size === next.size && [...next].every((id) => current.has(id)) ? current : next,
      );
    };
    const intersection = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          const id = (entry.target as HTMLElement).dataset.historyRun;
          if (!id) continue;
          if (entry.isIntersecting) intersecting.current.add(id);
          else {
            nodes.current.get(id)?.measure();
            intersecting.current.delete(id);
          }
        }
        update();
      },
      { root: scroll, rootMargin: "1200px 0px" },
    );
    observer.current = intersection;
    for (const { node } of nodes.current.values()) intersection.observe(node);
    scroll.ownerDocument.addEventListener("selectionchange", update);
    scroll.ownerDocument.addEventListener("focusin", update);
    scroll.ownerDocument.addEventListener("focusout", update);
    return () => {
      intersection.disconnect();
      observer.current = null;
      intersecting.current.clear();
      scroll.ownerDocument.removeEventListener("selectionchange", update);
      scroll.ownerDocument.removeEventListener("focusin", update);
      scroll.ownerDocument.removeEventListener("focusout", update);
    };
  }, [root, windowed]);
  const partition = conversationRunPartition(runs);
  const renderHistory = useMemo(() => {
    const retained = new Set<string>();
    const rows = partition.history.map((run, index) => {
      const show =
        !windowed ||
        run.status === "running" ||
        index >= runs.length - retainedTailRuns ||
        (!wasWindowed.current && mounted.current.has(run.runId)) ||
        visible.has(run.runId) ||
        pinnedRunIds?.has(run.runId) === true;
      if (show) retained.add(run.runId);
      return (
        <HistoryRun
          key={run.runId}
          run={run}
          mounted={show}
          register={register}
          renderRun={renderRun}
          retainedStates={retainedStates}
          measuredHeights={measuredHeights}
        />
      );
    });
    return { rows, retained };
  }, [
    partition.history,
    windowed,
    visible,
    pinnedRunIds,
    register,
    renderRun,
    runs.length,
    retainedStates,
    measuredHeights,
  ]);
  const nextMounted = new Set(renderHistory.retained);
  const tail = partition.tail;
  if (tail) nextMounted.add(tail.runId);
  useLayoutEffect(() => {
    mounted.current = nextMounted;
    wasWindowed.current = windowed;
  });
  return [
    ...renderHistory.rows,
    tail && (
      <HistoryRun
        key={tail.runId}
        run={tail}
        mounted
        register={register}
        renderRun={renderRun}
        retainedStates={retainedStates}
        measuredHeights={measuredHeights}
      />
    ),
  ];
});

// Large histories retain one lightweight placeholder per chunk instead of one per run.
// Existing small histories keep their established per-run anchoring behavior.
const historyChunkSize = 64;
const chunkThreshold = 256;
type HistoryGroup = { key: string; runs: RunView[] };

export function historyChunkForRun(root: HTMLElement, index: number): HTMLElement | undefined {
  return Array.from(root.querySelectorAll<HTMLElement>("[data-history-chunk]")).find(
    (node) => index >= Number(node.dataset.historyStart) && index < Number(node.dataset.historyEnd),
  );
}

// Keep existing group identities when pagination prepends or a new turn appends.
// Repartitioning every group would remount selected text and focused controls.
function groupHistory(
  runs: readonly RunView[],
  previous: { runs: readonly RunView[]; groups: HistoryGroup[] },
) {
  const start = previous.runs.length ? runs.findIndex((run) => run.runId === previous.runs[0].runId) : -1;
  const extendsPrevious =
    start >= 0 && previous.runs.every((run, index) => runs[start + index]?.runId === run.runId);
  const sliceGroups = (items: readonly RunView[]) => {
    const groups: HistoryGroup[] = [];
    for (let index = 0; index < items.length; index += historyChunkSize)
      groups.push({ key: items[index].runId, runs: items.slice(index, index + historyChunkSize) });
    return groups;
  };
  if (!extendsPrevious) return sliceGroups(runs);
  let offset = start;
  const groups = previous.groups.map((group) => {
    const next = { key: group.key, runs: runs.slice(offset, offset + group.runs.length) };
    offset += group.runs.length;
    return next;
  });
  const prefix = runs.slice(0, start);
  const first = groups[0];
  const prependCount = Math.min(prefix.length, historyChunkSize - first.runs.length);
  first.runs.unshift(...prefix.slice(prefix.length - prependCount));
  const suffix = runs.slice(offset);
  const last = groups[groups.length - 1];
  const appendCount = Math.min(suffix.length, historyChunkSize - last.runs.length);
  last.runs.push(...suffix.slice(0, appendCount));
  return [
    ...sliceGroups(prefix.slice(0, prefix.length - prependCount)),
    ...groups,
    ...sliceGroups(suffix.slice(appendCount)),
  ];
}

const HistoryChunk = memo(function HistoryChunk({
  runs,
  root,
  initialRunId,
  pinnedRunIds,
  renderRun,
  index,
  start,
  live,
  retainedStates,
  measuredHeights,
  transitionRunIds,
}: CachedHistoryProps & { index: number; start: number; live: boolean }) {
  const ref = useRef<HTMLDivElement>(null);
  const height = useRef(
    runs.reduce((total, run) => total + (measuredHeights.get(run.runId) ?? estimatedRunHeight), 0),
  );
  const previousCount = useRef(runs.length);
  if (previousCount.current !== runs.length) {
    height.current = runs.reduce(
      (total, run) => total + (measuredHeights.get(run.runId) ?? estimatedRunHeight),
      0,
    );
    previousCount.current = runs.length;
  }
  const [observed, setObserved] = useState(false);
  const [near, setNear] = useState(false);
  const [interaction, setInteraction] = useState(false);
  const forced =
    live ||
    runs.some(
      (run) =>
        run.status === "running" ||
        run.runId === initialRunId ||
        pinnedRunIds?.has(run.runId) ||
        (!observed && transitionRunIds?.has(run.runId)),
    );
  const active = near || interaction || forced;
  const pinned = useMemo(
    () =>
      !observed && transitionRunIds ? new Set([...(pinnedRunIds ?? []), ...transitionRunIds]) : pinnedRunIds,
    [observed, transitionRunIds, pinnedRunIds],
  );
  useEffect(() => {
    const node = ref.current;
    const scroll = root.current;
    if (!node || !scroll) return;
    const observer = new IntersectionObserver(
      (entries) => {
        setNear(entries.some((entry) => entry.isIntersecting));
        setObserved(true);
      },
      { root: scroll, rootMargin: "1200px 0px" },
    );
    observer.observe(node);
    const update = () => setInteraction(hasHistoryInteraction(node));
    node.ownerDocument.addEventListener("selectionchange", update);
    node.ownerDocument.addEventListener("focusin", update);
    node.ownerDocument.addEventListener("focusout", update);
    return () => {
      observer.disconnect();
      node.ownerDocument.removeEventListener("selectionchange", update);
      node.ownerDocument.removeEventListener("focusin", update);
      node.ownerDocument.removeEventListener("focusout", update);
    };
  }, [root]);
  useLayoutEffect(() => {
    const node = ref.current;
    if (!node || !active) return;
    const measure = () => {
      const value = node.getBoundingClientRect().height;
      if (value > 0) height.current = value;
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(node);
    return () => {
      measure();
      observer.disconnect();
    };
  }, [active]);
  useLayoutEffect(() => {
    root.current?.dispatchEvent(new Event("zpi-history-window"));
  }, [active, root]);
  return (
    <div
      ref={ref}
      data-history-chunk={index}
      data-history-start={start}
      data-history-end={start + runs.length}
      style={active ? undefined : { height: height.current, overflowAnchor: "none" }}
    >
      {active && (
        <WindowedHistory
          runs={runs}
          root={root}
          windowed
          initialRunId={initialRunId}
          pinnedRunIds={pinned}
          renderRun={renderRun}
          retainedStates={retainedStates}
          measuredHeights={measuredHeights}
        />
      )}
    </div>
  );
});

export const ConversationHistory = memo(function ConversationHistory(props: HistoryProps) {
  const { runs, windowed } = props;
  const [layoutLocked, setLayoutLocked] = useState(false);
  const chunked = useRef(false);
  useEffect(() => {
    const root = props.root.current;
    if (!root) return;
    const update = () => setLayoutLocked(hasHistoryInteraction(root));
    update();
    root.ownerDocument.addEventListener("selectionchange", update);
    root.ownerDocument.addEventListener("focusin", update);
    root.ownerDocument.addEventListener("focusout", update);
    return () => {
      root.ownerDocument.removeEventListener("selectionchange", update);
      root.ownerDocument.removeEventListener("focusin", update);
      root.ownerDocument.removeEventListener("focusout", update);
    };
  }, [props.root]);
  const layoutAnchor = useRef<{ id: string; top: number } | null>(null);
  const nextChunked = layoutLocked ? chunked.current : windowed && runs.length >= chunkThreshold;
  if (nextChunked !== chunked.current) {
    const root = props.root.current;
    const top = root?.getBoundingClientRect().top ?? 0;
    const anchor = Array.from(
      root?.querySelectorAll<HTMLElement>('[data-history-mounted="true"]') ?? [],
    ).find((node) => node.getBoundingClientRect().bottom > top);
    layoutAnchor.current = anchor?.dataset.historyRun
      ? { id: anchor.dataset.historyRun, top: anchor.getBoundingClientRect().top }
      : null;
    chunked.current = nextChunked;
  }
  useLayoutEffect(() => {
    const root = props.root.current;
    const anchor = layoutAnchor.current;
    layoutAnchor.current = null;
    if (!root || !anchor) return;
    const node = Array.from(root.querySelectorAll<HTMLElement>("[data-history-run]")).find(
      (node) => node.dataset.historyRun === anchor.id,
    );
    if (node) root.scrollTop += node.getBoundingClientRect().top - anchor.top;
  });
  const partition = conversationRunPartition(runs);
  const savedStates = useRef<SavedStates>(new Map());
  const heights = useRef(new Map<string, number>());
  const transitionRunIds = useMemo(
    () =>
      new Set(
        Array.from(
          props.root.current?.querySelectorAll<HTMLElement>('[data-history-mounted="true"]') ?? [],
        ).map((node) => node.dataset.historyRun ?? ""),
      ),
    [partition.history, props.root],
  );
  const shared = {
    ...props,
    retainedStates: savedStates.current,
    measuredHeights: heights.current,
    transitionRunIds,
  };
  const previousGroups = useRef<{ runs: readonly RunView[]; groups: HistoryGroup[] }>({
    runs: [],
    groups: [],
  });
  const historyChunks = useMemo(() => {
    const groups = groupHistory(partition.history, previousGroups.current);
    previousGroups.current = { runs: partition.history, groups };
    return groups;
  }, [partition.history]);
  const chunks = useMemo(() => {
    const groups = [...historyChunks];
    if (partition.tail) {
      if (groups.length && groups[groups.length - 1].runs.length < historyChunkSize) {
        const last = groups[groups.length - 1];
        groups[groups.length - 1] = { ...last, runs: [...last.runs, partition.tail] };
      } else groups.push({ key: partition.tail.runId, runs: [partition.tail] });
    }
    return groups;
  }, [historyChunks, partition.tail]);
  if (!chunked.current) return <WindowedHistory {...shared} />;
  let start = 0;
  return (
    <>
      {chunks.map((group, index) => {
        const first = start;
        start += group.runs.length;
        return (
          <HistoryChunk
            {...shared}
            key={group.key}
            runs={group.runs}
            index={index}
            start={first}
            live={index === chunks.length - 1}
          />
        );
      })}
    </>
  );
});