Upgrade React 18.3.1 → 19.2.4 with full type compatibility. Changes: - react, react-dom → ^19.0.0 - @types/react, @types/react-dom → ^19.0.0 - lucide-react → ^0.577.0 (React 19 type fixes) - @tiptap/* → ^3.20.4 (React 19 support) - useRef calls now require explicit initial value (undefined) - RefObject types updated for React 19 (includes null) - MutableRefObject → RefObject (deprecated in 19) - act() import moved from react-dom/test-utils to react - Scoped JSX namespace imports added where needed
524 lines
18 KiB
TypeScript
524 lines
18 KiB
TypeScript
/**
|
|
* Unified Tab Navigation Controller
|
|
*
|
|
* Single active-tab controller that replaces useNavigationCoordinator + useSearchContextNavigation.
|
|
* Manages the complete lifecycle of navigation requests with proper sequencing:
|
|
*
|
|
* 1. Receive pending navigation request from tab state
|
|
* 2. Ignore if tab is not active (prevents cross-tab races)
|
|
* 3. Wait for content to load
|
|
* 4. Expand target group and item
|
|
* 5. Wait for DOM to stabilize
|
|
* 6. Scroll to target
|
|
* 7. Set highlight (red for error, yellow for search)
|
|
* 8. Clear highlight after timeout
|
|
* 9. Consume the navigation request (mark as processed)
|
|
*
|
|
* The nonce-based request model ensures:
|
|
* - Repeated clicks create new navigations
|
|
* - Tab switches don't re-trigger stale requests
|
|
* - Auto-scroll is suppressed during navigation
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
import { isErrorPayload, isSearchPayload } from '@renderer/types/tabs';
|
|
|
|
import {
|
|
calculateCenteredScrollTop,
|
|
findAIGroupBySubagentId,
|
|
findAIGroupByTimestamp,
|
|
findChatItemByTimestamp,
|
|
findCurrentSearchResultInContainer,
|
|
waitForElementStability,
|
|
waitForScrollEnd,
|
|
} from './navigation/utils';
|
|
|
|
import type { SessionConversation } from '@renderer/types/groups';
|
|
import type { TabNavigationRequest } from '@renderer/types/tabs';
|
|
import type { TriggerColor } from '@shared/constants/triggerColors';
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
export type NavigationPhase =
|
|
| 'idle' // No navigation in progress
|
|
| 'pending' // Navigation requested, waiting for content
|
|
| 'expanding' // Expanding target group/item
|
|
| 'scrolling' // Scrolling to target
|
|
| 'highlighting' // Showing highlight ring
|
|
| 'complete'; // Navigation done, waiting to clear highlight
|
|
|
|
interface UseTabNavigationControllerOptions {
|
|
/** Whether this tab instance is currently the active tab */
|
|
isActiveTab: boolean;
|
|
/** Pending navigation request from tab state (undefined = no request) */
|
|
pendingNavigation?: TabNavigationRequest;
|
|
/** Conversation data (null while loading) */
|
|
conversation: SessionConversation | null;
|
|
/** Whether conversation is currently loading */
|
|
conversationLoading: boolean;
|
|
/** Function to consume (mark as processed) a navigation request */
|
|
consumeTabNavigation: (tabId: string, requestId: string) => void;
|
|
/** Tab ID for consuming navigation */
|
|
tabId: string;
|
|
/** Refs to AI group DOM elements */
|
|
aiGroupRefs: React.RefObject<Map<string, HTMLElement>>;
|
|
/** Refs to individual chat item DOM elements */
|
|
chatItemRefs: React.RefObject<Map<string, HTMLElement>>;
|
|
/** Refs to individual tool item DOM elements */
|
|
toolItemRefs: React.RefObject<Map<string, HTMLElement>>;
|
|
/** Function to expand an AI group (per-tab state) */
|
|
expandAIGroup: (groupId: string) => void;
|
|
/** Ref to scroll container */
|
|
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
|
/** Height of sticky elements at top of scroll container */
|
|
stickyOffset?: number;
|
|
/** Optional helper to ensure a target group is mounted (e.g., virtualized lists) */
|
|
ensureGroupVisible?: (groupId: string) => Promise<void> | void;
|
|
/** Function to expand a subagent trace (persists in per-tab state) */
|
|
expandSubagentTrace: (subagentId: string) => void;
|
|
/** Function to set search query in the search bar */
|
|
setSearchQuery: (query: string) => void;
|
|
/** Function to select an exact search match by item identity */
|
|
selectSearchMatch: (itemId: string, matchIndexInItem: number) => boolean;
|
|
/** Highlight duration in ms (default: 3000) */
|
|
highlightDuration?: number;
|
|
}
|
|
|
|
interface UseTabNavigationControllerReturn {
|
|
/** Current navigation phase */
|
|
phase: NavigationPhase;
|
|
/** Currently highlighted group ID */
|
|
highlightedGroupId: string | null;
|
|
/** Tool use ID to highlight */
|
|
highlightToolUseId: string | null;
|
|
/** Whether this is a search-based highlight (yellow) */
|
|
isSearchHighlight: boolean;
|
|
/** Custom highlight color from trigger (undefined = default red) */
|
|
highlightColor: TriggerColor | undefined;
|
|
/** Whether auto-scroll should be disabled */
|
|
shouldDisableAutoScroll: boolean;
|
|
/** Set highlighted group (for external control, e.g., turn navigation) */
|
|
setHighlightedGroupId: (id: string | null) => void;
|
|
/** Handle highlight end (clear highlight) */
|
|
handleHighlightEnd: () => void;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Hook Implementation
|
|
// =============================================================================
|
|
|
|
export function useTabNavigationController(
|
|
options: UseTabNavigationControllerOptions
|
|
): UseTabNavigationControllerReturn {
|
|
const {
|
|
isActiveTab,
|
|
pendingNavigation,
|
|
conversation,
|
|
conversationLoading,
|
|
consumeTabNavigation,
|
|
tabId,
|
|
aiGroupRefs,
|
|
chatItemRefs,
|
|
toolItemRefs,
|
|
expandAIGroup,
|
|
scrollContainerRef,
|
|
stickyOffset = 0,
|
|
ensureGroupVisible,
|
|
expandSubagentTrace,
|
|
setSearchQuery,
|
|
selectSearchMatch,
|
|
highlightDuration = 3000,
|
|
} = options;
|
|
|
|
// State
|
|
const [phase, setPhase] = useState<NavigationPhase>('idle');
|
|
const [highlightedGroupId, setHighlightedGroupId] = useState<string | null>(null);
|
|
const [currentToolUseId, setCurrentToolUseId] = useState<string | null>(null);
|
|
const [isSearchHighlight, setIsSearchHighlight] = useState(false);
|
|
const [highlightColor, setHighlightColor] = useState<TriggerColor | undefined>(undefined);
|
|
|
|
// Refs for tracking
|
|
const activeRequestIdRef = useRef<string | null>(null);
|
|
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
const lastFailureAtRef = useRef<number>(0);
|
|
|
|
// Clear highlight and reset state
|
|
const handleHighlightEnd = useCallback(() => {
|
|
setHighlightedGroupId(null);
|
|
setCurrentToolUseId(null);
|
|
setIsSearchHighlight(false);
|
|
setHighlightColor(undefined);
|
|
setPhase('idle');
|
|
activeRequestIdRef.current = null;
|
|
|
|
if (highlightTimerRef.current) {
|
|
clearTimeout(highlightTimerRef.current);
|
|
highlightTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
// Abort any in-progress navigation
|
|
const abortNavigation = useCallback(() => {
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
abortControllerRef.current = null;
|
|
}
|
|
if (highlightTimerRef.current) {
|
|
clearTimeout(highlightTimerRef.current);
|
|
highlightTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
// Execute error navigation sequence
|
|
const executeErrorNavigation = useCallback(
|
|
async (request: TabNavigationRequest, abortSignal: AbortSignal): Promise<boolean> => {
|
|
if (!isErrorPayload(request) || !conversation) return false;
|
|
const { errorTimestamp, toolUseId, subagentId } = request.payload;
|
|
|
|
const checkAborted = (): boolean => abortSignal.aborted;
|
|
|
|
// Find target AI group (subagent-aware lookup first, then timestamp fallback)
|
|
let targetGroupId: string | null = null;
|
|
if (subagentId) {
|
|
targetGroupId = findAIGroupBySubagentId(conversation.items, subagentId);
|
|
}
|
|
if (!targetGroupId && errorTimestamp > 0) {
|
|
targetGroupId = findAIGroupByTimestamp(conversation.items, errorTimestamp);
|
|
}
|
|
if (!targetGroupId) {
|
|
// Fallback: last AI group
|
|
const aiItems = conversation.items.filter((item) => item.type === 'ai');
|
|
if (aiItems.length > 0) {
|
|
targetGroupId = aiItems[aiItems.length - 1].group.id;
|
|
}
|
|
}
|
|
if (!targetGroupId) return false;
|
|
|
|
// Phase 1: Expanding
|
|
setPhase('expanding');
|
|
expandAIGroup(targetGroupId);
|
|
// Persist subagent trace expansion so it survives highlight clearing
|
|
if (subagentId) {
|
|
expandSubagentTrace(subagentId);
|
|
}
|
|
await ensureGroupVisible?.(targetGroupId);
|
|
if (checkAborted()) return false;
|
|
|
|
// Set highlight early so it's visible even if scroll is imperfect
|
|
setHighlightedGroupId(targetGroupId);
|
|
setIsSearchHighlight(false);
|
|
// Error navigation uses a TriggerColor (preset key or custom hex, defaulting to 'red')
|
|
setHighlightColor(request.highlight === 'none' ? undefined : request.highlight);
|
|
if (toolUseId) setCurrentToolUseId(toolUseId);
|
|
|
|
// Wait for element to exist and stabilize
|
|
let element: HTMLElement | undefined;
|
|
const elementLookupStart = Date.now();
|
|
while (Date.now() - elementLookupStart < 600) {
|
|
element = aiGroupRefs.current.get(targetGroupId);
|
|
if (element) break;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
if (checkAborted()) return false;
|
|
await ensureGroupVisible?.(targetGroupId);
|
|
}
|
|
// If element not found, highlight is already set — return success
|
|
if (!element) return true;
|
|
await waitForElementStability(element, 250, 2);
|
|
if (checkAborted()) return false;
|
|
|
|
// Phase 2: Scrolling (best-effort — highlight already set)
|
|
setPhase('scrolling');
|
|
|
|
// Wait for tool item ref if needed (longer timeout for subagent cascading expansion)
|
|
let toolElement: HTMLElement | undefined;
|
|
if (toolUseId) {
|
|
// Subagents need more time: AI group expand → display item expand → trace expand → tool render
|
|
const toolLookupTimeout = subagentId ? 1200 : 300;
|
|
const startTime = Date.now();
|
|
while (Date.now() - startTime < toolLookupTimeout) {
|
|
toolElement = toolItemRefs.current.get(toolUseId);
|
|
if (toolElement) break;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
if (checkAborted()) return true; // Highlight already set
|
|
}
|
|
if (toolElement) {
|
|
await waitForElementStability(toolElement, 300, 2);
|
|
if (checkAborted()) return true; // Highlight already set
|
|
}
|
|
}
|
|
|
|
// Scroll to target (best-effort)
|
|
const targetElement = toolElement ?? element;
|
|
const container = scrollContainerRef.current;
|
|
if (targetElement && container) {
|
|
const targetScrollTop = calculateCenteredScrollTop(targetElement, container, stickyOffset);
|
|
container.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
|
|
await waitForScrollEnd(container, 400);
|
|
}
|
|
if (checkAborted()) return false;
|
|
|
|
// Phase 3: Highlight was set early, just update phase
|
|
setPhase('highlighting');
|
|
return true;
|
|
},
|
|
[
|
|
conversation,
|
|
expandAIGroup,
|
|
expandSubagentTrace,
|
|
aiGroupRefs,
|
|
toolItemRefs,
|
|
scrollContainerRef,
|
|
stickyOffset,
|
|
ensureGroupVisible,
|
|
]
|
|
);
|
|
|
|
// Execute search navigation sequence
|
|
const executeSearchNavigation = useCallback(
|
|
async (request: TabNavigationRequest, abortSignal: AbortSignal): Promise<boolean> => {
|
|
if (!isSearchPayload(request) || !conversation) return false;
|
|
const { query, messageTimestamp, targetGroupId, targetMatchIndexInItem } = request.payload;
|
|
|
|
const checkAborted = (): boolean => abortSignal.aborted;
|
|
|
|
// Find target chat item (prefer exact group ID when provided)
|
|
const exactTargetItem =
|
|
targetGroupId !== undefined
|
|
? conversation.items.find((item) => item.group.id === targetGroupId)
|
|
: undefined;
|
|
const targetItem =
|
|
exactTargetItem &&
|
|
(exactTargetItem.type === 'user' ||
|
|
exactTargetItem.type === 'system' ||
|
|
exactTargetItem.type === 'ai' ||
|
|
exactTargetItem.type === 'compact')
|
|
? { groupId: exactTargetItem.group.id, type: exactTargetItem.type }
|
|
: findChatItemByTimestamp(conversation.items, messageTimestamp);
|
|
if (!targetItem) return false;
|
|
|
|
// Phase 1: Expanding
|
|
setPhase('expanding');
|
|
setSearchQuery(query);
|
|
if (targetGroupId !== undefined && targetMatchIndexInItem !== undefined) {
|
|
selectSearchMatch(targetGroupId, targetMatchIndexInItem);
|
|
}
|
|
setHighlightedGroupId(targetItem.groupId);
|
|
setIsSearchHighlight(true);
|
|
await ensureGroupVisible?.(targetItem.groupId);
|
|
if (checkAborted()) return false;
|
|
|
|
// Wait for element to appear
|
|
const startedAt = Date.now();
|
|
let targetEl: Element | null = null;
|
|
|
|
while (!checkAborted() && Date.now() - startedAt < 600) {
|
|
targetEl = findCurrentSearchResultInContainer(
|
|
scrollContainerRef.current,
|
|
targetGroupId,
|
|
targetMatchIndexInItem
|
|
);
|
|
if (!targetEl) {
|
|
targetEl =
|
|
chatItemRefs.current.get(targetItem.groupId) ??
|
|
aiGroupRefs.current.get(targetItem.groupId) ??
|
|
null;
|
|
}
|
|
if (targetEl) break;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
await ensureGroupVisible?.(targetItem.groupId);
|
|
}
|
|
|
|
if (checkAborted()) return false;
|
|
// If element not found, highlight is already set — return success
|
|
if (!targetEl) return true;
|
|
|
|
// Phase 2: Scrolling (best-effort — highlight already set)
|
|
setPhase('scrolling');
|
|
const container = scrollContainerRef.current;
|
|
if (container && targetEl instanceof HTMLElement) {
|
|
const targetScrollTop = calculateCenteredScrollTop(targetEl, container, stickyOffset);
|
|
container.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
|
|
await waitForScrollEnd(container, 400);
|
|
} else if (targetEl instanceof HTMLElement) {
|
|
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
}
|
|
|
|
if (checkAborted()) return false;
|
|
|
|
// Phase 3: Highlighting (yellow for search)
|
|
setPhase('highlighting');
|
|
// highlightedGroupId and isSearchHighlight already set above
|
|
|
|
return true;
|
|
},
|
|
[
|
|
conversation,
|
|
scrollContainerRef,
|
|
chatItemRefs,
|
|
aiGroupRefs,
|
|
stickyOffset,
|
|
ensureGroupVisible,
|
|
setSearchQuery,
|
|
selectSearchMatch,
|
|
]
|
|
);
|
|
|
|
// Main navigation executor
|
|
const executeNavigation = useCallback(
|
|
async (request: TabNavigationRequest): Promise<void> => {
|
|
abortNavigation();
|
|
const abortController = new AbortController();
|
|
abortControllerRef.current = abortController;
|
|
|
|
try {
|
|
let success = false;
|
|
|
|
if (request.kind === 'error') {
|
|
success = await executeErrorNavigation(request, abortController.signal);
|
|
} else if (request.kind === 'search') {
|
|
success = await executeSearchNavigation(request, abortController.signal);
|
|
} else if (request.kind === 'autoBottom') {
|
|
// autoBottom is handled by useAutoScrollBottom naturally
|
|
// Just consume the request and stay idle
|
|
consumeTabNavigation(tabId, request.id);
|
|
return;
|
|
}
|
|
|
|
if (abortController.signal.aborted) return;
|
|
|
|
if (success) {
|
|
// Schedule highlight end
|
|
highlightTimerRef.current = setTimeout(() => {
|
|
if (!abortController.signal.aborted) {
|
|
// Clear search state if it was a search navigation
|
|
if (request.kind === 'search') {
|
|
setSearchQuery('');
|
|
}
|
|
handleHighlightEnd();
|
|
}
|
|
}, highlightDuration);
|
|
|
|
setPhase('complete');
|
|
} else {
|
|
// Navigation failed - reset
|
|
setPhase('idle');
|
|
setHighlightedGroupId(null);
|
|
setCurrentToolUseId(null);
|
|
setIsSearchHighlight(false);
|
|
setHighlightColor(undefined);
|
|
activeRequestIdRef.current = null;
|
|
lastFailureAtRef.current = Date.now();
|
|
}
|
|
|
|
// Consume the request regardless of success/failure to prevent re-processing
|
|
consumeTabNavigation(tabId, request.id);
|
|
} catch {
|
|
if (!abortController.signal.aborted) {
|
|
setPhase('idle');
|
|
activeRequestIdRef.current = null;
|
|
lastFailureAtRef.current = Date.now();
|
|
consumeTabNavigation(tabId, request.id);
|
|
}
|
|
}
|
|
},
|
|
[
|
|
abortNavigation,
|
|
executeErrorNavigation,
|
|
executeSearchNavigation,
|
|
consumeTabNavigation,
|
|
tabId,
|
|
highlightDuration,
|
|
handleHighlightEnd,
|
|
setSearchQuery,
|
|
]
|
|
);
|
|
|
|
// Effect: Detect and process new navigation requests
|
|
useEffect(() => {
|
|
// Ignore if not active tab (prevents cross-tab races)
|
|
if (!isActiveTab) return;
|
|
|
|
// No pending request
|
|
if (!pendingNavigation) return;
|
|
|
|
// Already processing this request
|
|
if (activeRequestIdRef.current === pendingNavigation.id) return;
|
|
|
|
// Recently failed - debounce
|
|
if (Date.now() - lastFailureAtRef.current < 500) return;
|
|
|
|
// Record this request
|
|
activeRequestIdRef.current = pendingNavigation.id;
|
|
|
|
// If content is loading, wait in pending state
|
|
if (conversationLoading || !conversation) {
|
|
queueMicrotask(() => setPhase('pending'));
|
|
return;
|
|
}
|
|
|
|
// Execute navigation (deferred to avoid synchronous setState in effect)
|
|
queueMicrotask(() => {
|
|
void executeNavigation(pendingNavigation);
|
|
});
|
|
}, [isActiveTab, pendingNavigation, conversationLoading, conversation, executeNavigation]);
|
|
|
|
// Effect: When content finishes loading and we're pending, start navigation
|
|
useEffect(() => {
|
|
if (phase !== 'pending') return;
|
|
if (!isActiveTab) return;
|
|
if (conversationLoading || !conversation) return;
|
|
if (!pendingNavigation) return;
|
|
|
|
queueMicrotask(() => {
|
|
void executeNavigation(pendingNavigation);
|
|
});
|
|
}, [phase, isActiveTab, conversationLoading, conversation, pendingNavigation, executeNavigation]);
|
|
|
|
// Effect: Reset when tab becomes inactive
|
|
useEffect(() => {
|
|
if (!isActiveTab && phase !== 'idle') {
|
|
abortNavigation();
|
|
queueMicrotask(() => {
|
|
setPhase('idle');
|
|
setHighlightedGroupId(null);
|
|
setCurrentToolUseId(null);
|
|
setIsSearchHighlight(false);
|
|
setHighlightColor(undefined);
|
|
});
|
|
activeRequestIdRef.current = null;
|
|
}
|
|
}, [isActiveTab, phase, abortNavigation]);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
abortNavigation();
|
|
};
|
|
}, [abortNavigation]);
|
|
|
|
// Computed: should disable auto-scroll
|
|
const shouldDisableAutoScroll =
|
|
phase === 'pending' ||
|
|
phase === 'expanding' ||
|
|
phase === 'scrolling' ||
|
|
phase === 'highlighting' ||
|
|
phase === 'complete' ||
|
|
// Also disable while any pendingNavigation exists (even before processing starts)
|
|
(isActiveTab && pendingNavigation !== undefined);
|
|
|
|
return {
|
|
phase,
|
|
highlightedGroupId,
|
|
highlightToolUseId: currentToolUseId,
|
|
isSearchHighlight,
|
|
highlightColor,
|
|
shouldDisableAutoScroll,
|
|
setHighlightedGroupId,
|
|
handleHighlightEnd,
|
|
};
|
|
}
|