Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

370 lines
11 KiB
TypeScript

import {
useEffect,
useLayoutEffect,
useRef,
useState,
type RefObject
} from "react";
import type { AgentMessageLocatorItem } from "./agentTranscriptModel";
import { escapeCssString } from "./agentTranscriptModel";
import type { AgentConversationFollowEndMode } from "../agentConversationFollowEndController";
const REVERSE_CONFIRMATION_FRAMES = 3;
const SCROLL_INTENT_WINDOW_MS = 1_000;
export interface AgentMessageLocatorVirtualSelectionSource {
readonly scrollOffset: number | null;
readonly scrollRect: { readonly height: number } | null;
getVirtualItemForOffset(
offset: number
): { readonly index: number } | undefined;
}
interface AgentMessageLocatorSelection {
selectItem(itemKey: string): void;
selectedKey: string | null;
}
export function useAgentMessageLocatorSelection({
followEndMode,
items,
isVisible,
locatorRef,
virtualSelectionSource
}: {
followEndMode?: AgentConversationFollowEndMode;
items: readonly AgentMessageLocatorItem[];
isVisible: boolean;
locatorRef: RefObject<HTMLElement | null>;
virtualSelectionSource?: AgentMessageLocatorVirtualSelectionSource;
}): AgentMessageLocatorSelection {
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const selectedIndexRef = useRef(-1);
const selectionDirectionRef = useRef<-1 | 0 | 1>(0);
const scrollIntentRef = useRef<{
direction: -1 | 1;
expiresAt: number;
} | null>(null);
const pendingReverseSelectionRef = useRef<{
count: number;
key: string;
} | null>(null);
const virtualSelectionReady =
!virtualSelectionSource ||
(virtualSelectionSource.scrollOffset !== null &&
virtualSelectionSource.scrollRect !== null);
useLayoutEffect(() => {
if (!isVisible) {
return;
}
if (!selectedKey) {
return;
}
const nextIndex = items.findIndex((item) => item.key === selectedKey);
if (nextIndex >= 0) {
const previousIndex = selectedIndexRef.current;
const intent = scrollIntentRef.current;
const intendedDirection =
intent && intent.expiresAt >= performance.now() ? intent.direction : 0;
const indexShiftDirection = Math.sign(nextIndex - previousIndex);
if (
previousIndex >= 0 &&
intendedDirection !== 0 &&
indexShiftDirection !== 0 &&
indexShiftDirection !== intendedDirection
) {
const replacement = items[Math.min(previousIndex, items.length - 1)];
selectedIndexRef.current = Math.min(previousIndex, items.length - 1);
if (replacement && replacement.key !== selectedKey) {
setSelectedKey(replacement.key);
}
return;
}
selectedIndexRef.current = nextIndex;
return;
}
selectionDirectionRef.current = 0;
pendingReverseSelectionRef.current = null;
const locator = locatorRef.current;
const scrollParent = locator
? findMessageLocatorScrollParent(locator)
: null;
const nextSelectedKey = virtualSelectionSource
? selectVirtualizedMessageLocatorItemAtViewportCenter(
virtualSelectionSource,
items,
scrollParent?.scrollTop ?? virtualSelectionSource.scrollOffset
)
: null;
const candidateIndex = items.findIndex(
(item) => item.key === nextSelectedKey
);
const previousIndex = selectedIndexRef.current;
const intent = scrollIntentRef.current;
const intendedDirection =
intent && intent.expiresAt >= performance.now() ? intent.direction : 0;
const candidateDirection = Math.sign(candidateIndex - previousIndex);
const replacementIndex =
previousIndex >= 0 &&
intendedDirection !== 0 &&
candidateDirection !== 0 &&
candidateDirection !== intendedDirection
? Math.min(previousIndex, items.length - 1)
: candidateIndex;
selectedIndexRef.current = replacementIndex;
setSelectedKey(items[replacementIndex]?.key ?? null);
}, [isVisible, items, locatorRef, selectedKey, virtualSelectionSource]);
useEffect(() => {
if (!isVisible) {
return;
}
const locator = locatorRef.current;
const scrollParent = locator
? findMessageLocatorScrollParent(locator)
: null;
if (!scrollParent) {
return;
}
let animationFrame: number | null = null;
const updateSelectedFromScroll = (): void => {
animationFrame = null;
const currentScrollTop = scrollParent.scrollTop;
const nextSelectedKey = virtualSelectionSource
? selectVirtualizedMessageLocatorItemAtViewportCenter(
virtualSelectionSource,
items,
currentScrollTop
)
: selectMessageLocatorItemAtViewportCenter(scrollParent, items);
if (!nextSelectedKey) {
return;
}
setSelectedKey((currentKey) => {
if (!currentKey || currentKey === nextSelectedKey) {
selectedIndexRef.current = items.findIndex(
(item) => item.key === nextSelectedKey
);
pendingReverseSelectionRef.current = null;
return nextSelectedKey;
}
const currentIndex = items.findIndex((item) => item.key === currentKey);
const nextIndex = items.findIndex(
(item) => item.key === nextSelectedKey
);
if (currentIndex < 0 || nextIndex < 0) {
selectedIndexRef.current = nextIndex;
selectionDirectionRef.current = 0;
pendingReverseSelectionRef.current = null;
return nextSelectedKey;
}
const nextDirection = Math.sign(nextIndex - currentIndex) as -1 | 0 | 1;
const scrollIntent = scrollIntentRef.current;
const intendedDirection =
scrollIntent && scrollIntent.expiresAt >= performance.now()
? scrollIntent.direction
: 0;
const followsSemanticEnd =
followEndMode === "following" && nextDirection === 1;
if (
!followsSemanticEnd &&
intendedDirection !== 0 &&
nextDirection !== 0 &&
nextDirection !== intendedDirection
) {
pendingReverseSelectionRef.current = null;
return currentKey;
}
const selectionDirection = selectionDirectionRef.current;
if (
!followsSemanticEnd &&
intendedDirection === 0 &&
currentIndex >= 0 &&
nextIndex >= 0 &&
selectionDirection !== 0 &&
nextDirection !== 0 &&
nextDirection !== selectionDirection
) {
const pending = pendingReverseSelectionRef.current;
const count =
pending?.key === nextSelectedKey ? pending.count + 1 : 1;
if (count < REVERSE_CONFIRMATION_FRAMES) {
pendingReverseSelectionRef.current = {
count,
key: nextSelectedKey
};
return currentKey;
}
}
if (nextDirection === 0) {
return currentKey;
}
selectionDirectionRef.current = nextDirection;
selectedIndexRef.current = nextIndex;
pendingReverseSelectionRef.current = null;
return nextSelectedKey;
});
};
const scheduleUpdate = (): void => {
if (animationFrame !== null) {
return;
}
animationFrame = window.requestAnimationFrame(updateSelectedFromScroll);
};
const captureWheelIntent = (event: globalThis.WheelEvent): void => {
if (event.deltaY === 0) {
return;
}
scrollIntentRef.current = {
direction: event.deltaY < 0 ? -1 : 1,
expiresAt: performance.now() + SCROLL_INTENT_WINDOW_MS
};
pendingReverseSelectionRef.current = null;
};
const captureKeyboardIntent = (event: KeyboardEvent): void => {
const direction =
event.key === "ArrowUp" ||
event.key === "Home" ||
event.key === "PageUp"
? -1
: event.key === "ArrowDown" ||
event.key === "End" ||
event.key === "PageDown" ||
event.key === " "
? 1
: 0;
if (direction === 0) {
return;
}
scrollIntentRef.current = {
direction,
expiresAt: performance.now() + SCROLL_INTENT_WINDOW_MS
};
pendingReverseSelectionRef.current = null;
};
scheduleUpdate();
scrollParent.addEventListener("scroll", scheduleUpdate, { passive: true });
scrollParent.addEventListener("wheel", captureWheelIntent, {
passive: true
});
scrollParent.addEventListener("keydown", captureKeyboardIntent);
return () => {
scrollParent.removeEventListener("scroll", scheduleUpdate);
scrollParent.removeEventListener("wheel", captureWheelIntent);
scrollParent.removeEventListener("keydown", captureKeyboardIntent);
if (animationFrame !== null) {
window.cancelAnimationFrame(animationFrame);
}
};
}, [
followEndMode,
isVisible,
items,
locatorRef,
virtualSelectionReady,
virtualSelectionSource
]);
const selectItem = (itemKey: string): void => {
selectionDirectionRef.current = 0;
scrollIntentRef.current = null;
pendingReverseSelectionRef.current = null;
setSelectedKey(itemKey);
};
return {
selectItem,
selectedKey
};
}
export function findMessageLocatorScrollParent(
locator: HTMLElement
): HTMLElement | null {
const timeline = locator.closest<HTMLElement>(
'[data-testid="agent-gui-timeline"]'
);
if (timeline) {
return timeline;
}
let current = locator.parentElement;
while (current) {
const style = window.getComputedStyle(current);
const overflowY = style.overflowY;
if (
(overflowY === "auto" || overflowY === "scroll") &&
current.scrollHeight > current.clientHeight
) {
return current;
}
current = current.parentElement;
}
return null;
}
function selectMessageLocatorItemAtViewportCenter(
scrollParent: HTMLElement,
items: readonly AgentMessageLocatorItem[]
): string | null {
const viewportRect = scrollParent.getBoundingClientRect();
const viewportCenterY = viewportRect.top + viewportRect.height / 2;
let nearest: { key: string; distance: number } | null = null;
for (const item of items) {
const row = scrollParent.querySelector<HTMLElement>(
`[data-agent-transcript-row="${escapeCssString(item.rowKey)}"]`
);
if (!row) {
continue;
}
const rowRect = row.getBoundingClientRect();
const rowCenterY = rowRect.top + rowRect.height / 2;
const distance = Math.abs(rowCenterY - viewportCenterY);
if (!nearest || distance < nearest.distance) {
nearest = { key: item.key, distance };
}
}
return nearest?.key ?? null;
}
function selectVirtualizedMessageLocatorItemAtViewportCenter(
source: AgentMessageLocatorVirtualSelectionSource,
items: readonly AgentMessageLocatorItem[],
observedScrollOffset = source.scrollOffset
): string | null {
const viewportHeight = source.scrollRect?.height;
if (observedScrollOffset === null || viewportHeight === undefined) {
return null;
}
const virtualTurn = source.getVirtualItemForOffset(
observedScrollOffset + viewportHeight / 2
);
if (!virtualTurn) {
return null;
}
let start = 0;
let end = items.length - 1;
let matchedIndex = -1;
while (start <= end) {
const middle = Math.floor((start + end) / 2);
const item = items[middle];
if (!item) {
break;
}
if (item.turnGroupIndex <= virtualTurn.index) {
matchedIndex = middle;
start = middle + 1;
} else {
end = middle - 1;
}
}
return items[matchedIndex < 0 ? 0 : matchedIndex]?.key ?? null;
}