Files
tutti/packages/agent/gui/agent-gui/agentGuiNode/view/useAgentGUIConversationRailViewState.ts
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

198 lines
6.2 KiB
TypeScript

import { useCallback, useLayoutEffect, useReducer, useRef } from "react";
import {
AGENT_GUI_CONVERSATION_RAIL_SECTION_PAGE_SIZE,
agentGUIConversationRailScopedViewState,
reduceAgentGUIConversationRailViewState,
type AgentGUIConversationRailRevealRequest
} from "../model/agentGuiConversationRailViewState";
import { useStableEventCallback } from "./agentGUIViewUtils";
interface UseAgentGUIConversationRailViewStateInput {
activeConversationId: string | null;
contentReady: boolean;
groupedConversationIdentityKey: string;
revealRequest: AgentGUIConversationRailRevealRequest | null;
searchQuery: string;
scopeKey: string;
}
interface RailScrollNavigationState {
handledRevealRevision: number;
navigationKey: string | null;
phase: "scope-pending" | "steady";
}
function applyRailScrollNavigation(input: {
activeConversationId: string | null;
contentReady: boolean;
conversationItemElements: ReadonlyMap<string, HTMLDivElement>;
current: RailScrollNavigationState;
navigationKey: string;
rememberedScrollTop: number | undefined;
revealRequest: AgentGUIConversationRailRevealRequest | null;
searchActive: boolean;
viewport: HTMLDivElement;
}): RailScrollNavigationState {
const activeConversationId = input.activeConversationId?.trim() || null;
const current =
input.current.navigationKey === input.navigationKey
? input.current
: {
handledRevealRevision: input.current.handledRevealRevision,
navigationKey: input.navigationKey,
phase: "scope-pending" as const
};
if (!input.contentReady) return current;
if (current.phase === "scope-pending") {
if (input.searchActive) {
input.viewport.scrollTop = 0;
} else if (input.rememberedScrollTop !== undefined) {
input.viewport.scrollTop = input.rememberedScrollTop;
} else if (activeConversationId) {
const activeElement =
input.conversationItemElements.get(activeConversationId);
if (activeElement) {
activeElement.scrollIntoView({ block: "nearest" });
} else {
return current;
}
} else {
input.viewport.scrollTop = 0;
}
return {
handledRevealRevision: current.handledRevealRevision,
navigationKey: input.navigationKey,
phase: "steady"
};
}
const revealRequest = input.revealRequest;
if (
!revealRequest ||
revealRequest.revision <= current.handledRevealRevision ||
revealRequest.agentSessionId !== activeConversationId
) {
return current;
}
const activeElement = input.conversationItemElements.get(
revealRequest.agentSessionId
);
if (!activeElement) return current;
activeElement.scrollIntoView({ block: "nearest" });
return { ...current, handledRevealRevision: revealRequest.revision };
}
export function useAgentGUIConversationRailViewState(
input: UseAgentGUIConversationRailViewStateInput
) {
const [viewState, dispatch] = useReducer(
reduceAgentGUIConversationRailViewState,
new Map()
);
const conversationListRef = useRef<HTMLDivElement | null>(null);
const conversationItemElementsRef = useRef(new Map<string, HTMLDivElement>());
const scrollTopByScopeRef = useRef(new Map<string, number>());
const navigationRef = useRef<RailScrollNavigationState>({
handledRevealRevision: 0,
navigationKey: null,
phase: "scope-pending"
});
const normalizedSearchQuery = input.searchQuery.trim();
const searchActive = normalizedSearchQuery.length > 0;
const navigationKey = searchActive
? `${input.scopeKey}:search:${normalizedSearchQuery}`
: input.scopeKey;
const scopedViewState = agentGUIConversationRailScopedViewState(
viewState,
input.scopeKey
);
useLayoutEffect(() => {
const viewport = conversationListRef.current;
if (!viewport) return;
const recordScrollTop = () => {
const navigation = navigationRef.current;
if (
!searchActive &&
navigation.navigationKey === navigationKey &&
navigation.phase === "steady"
) {
scrollTopByScopeRef.current.set(input.scopeKey, viewport.scrollTop);
}
};
viewport.addEventListener("scroll", recordScrollTop, { passive: true });
const previousNavigation = navigationRef.current;
const nextNavigation = applyRailScrollNavigation({
activeConversationId: input.activeConversationId,
contentReady: input.contentReady,
conversationItemElements: conversationItemElementsRef.current,
current: previousNavigation,
navigationKey,
rememberedScrollTop: searchActive
? undefined
: scrollTopByScopeRef.current.get(input.scopeKey),
revealRequest: input.revealRequest,
searchActive,
viewport
});
navigationRef.current = nextNavigation;
if (
nextNavigation.phase === "steady" &&
nextNavigation !== previousNavigation
) {
recordScrollTop();
}
return () => {
viewport.removeEventListener("scroll", recordScrollTop);
};
}, [
input.activeConversationId,
input.contentReady,
input.groupedConversationIdentityKey,
input.revealRequest,
input.scopeKey,
navigationKey,
searchActive
]);
const registerConversationItemElement = useCallback(
(itemId: string, element: HTMLDivElement | null) => {
if (element) {
conversationItemElementsRef.current.set(itemId, element);
} else {
conversationItemElementsRef.current.delete(itemId);
}
},
[]
);
const setSectionVisibleItemLimit = useStableEventCallback(
(sectionId: string, limit: number) => {
dispatch({
type: "section-visible-limit-set",
limit,
scopeKey: input.scopeKey,
sectionId
});
}
);
const toggleProjectSectionCollapsed = useStableEventCallback(
(sectionId: string) => {
dispatch({
type: "section-collapsed-toggled",
scopeKey: input.scopeKey,
sectionId
});
}
);
return {
collapsedSectionIds: scopedViewState.collapsedSectionIds,
conversationListRef,
registerConversationItemElement,
setSectionVisibleItemLimit,
toggleProjectSectionCollapsed,
visibleItemLimitForSection: (sectionId: string) =>
scopedViewState.visibleItemLimitBySectionId.get(sectionId) ??
AGENT_GUI_CONVERSATION_RAIL_SECTION_PAGE_SIZE
};
}