--- name: claude-devtools:navigation-scroll description: Navigation and scroll orchestration — tab navigation, error highlights, search scrolling, auto-scroll coordination, and common bug patterns. Use when working on useTabNavigationController, scroll restore, or navigation requests. --- # Navigation & Scroll Orchestration How tab navigation (error highlights, search scrolling, auto-scroll) works end-to-end. ## Architecture ### Navigation Request Model (Nonce-Based) ```typescript // src/renderer/types/tabs.ts interface TabNavigationRequest { id: string; // crypto.randomUUID() — fresh nonce per click kind: 'error' | 'search' | 'autoBottom'; highlight: 'red' | 'yellow' | 'none'; payload: ErrorNavigationPayload | SearchNavigationPayload | {}; source: 'notification' | 'triggerPreview' | 'commandPalette' | 'sessionOpen'; } // Stored on Tab: interface Tab { pendingNavigation?: TabNavigationRequest; // Set by enqueue, cleared by consume lastConsumedNavigationId?: string; // Tracks last processed request } ``` ### Store Actions (tabSlice.ts) | Action | Purpose | |--------|---------| | `enqueueTabNavigation(tabId, request)` | Set `pendingNavigation` on a tab | | `consumeTabNavigation(tabId, requestId)` | Clear `pendingNavigation`, record `lastConsumedNavigationId` | ### Navigation Sources | Source | Slice | Creates | |--------|-------|---------| | Notification click / test trigger | `notificationSlice.navigateToError()` | `ErrorNavigationRequest` (red) | | CommandPalette search result | `tabSlice.navigateToSession()` | `SearchNavigationRequest` (yellow) | ### Controller Hook: `useTabNavigationController` **Location:** `src/renderer/hooks/useTabNavigationController.ts` Phase state machine: ``` idle → pending → expanding → scrolling → highlighting → complete → idle ``` Key behaviors: - **Active-tab-only:** Ignores `!isActiveTab` to prevent cross-tab races - **Nonce dedup:** `activeRequestIdRef.current === pendingNavigation.id` prevents reprocessing - **Failure debounce:** 500ms cooldown after failed navigation (`lastFailureAtRef`) - **Abort support:** New navigation aborts in-progress one via `AbortController` - **Highlight-first:** Highlight is set BEFORE scroll (best-effort scroll, guaranteed highlight) ### Scroll Precedence (ChatHistory.tsx) Three scroll systems compete — navigation wins: | System | Guard | Priority | |--------|-------|----------| | Navigation scroll | Controller's `executeNavigation` | Highest | | Scroll restore (tab switch) | `!shouldDisableAutoScroll` | Medium | | Auto-scroll to bottom | `disabled: shouldDisableAutoScroll` | Lowest | `shouldDisableAutoScroll` is `true` during ANY navigation phase or when `pendingNavigation` exists. ## Key Files | File | Role | |------|------| | `src/renderer/hooks/useTabNavigationController.ts` | Unified navigation controller | | `src/renderer/hooks/navigation/utils.ts` | Shared helpers (scroll calc, element lookup, visibility) | | `src/renderer/components/chat/ChatHistory.tsx` | Scroll restore + auto-scroll coordination | | `src/renderer/store/slices/tabSlice.ts` | `enqueueTabNavigation`, `consumeTabNavigation`, `navigateToSession` | | `src/renderer/store/slices/notificationSlice.ts` | `navigateToError` | | `src/renderer/store/slices/sessionDetailSlice.ts` | `fetchSessionDetail` (sets `conversationLoading`) | | `src/renderer/types/tabs.ts` | `TabNavigationRequest` types + factory helpers | ## Common Bug Patterns ### 1. Scroll Restore Overrides Navigation **Symptom:** Scrolls to target, then snaps back to top/previous position. **Root cause:** The scroll restore effect fires after `consumeTabNavigation` clears `pendingNavigation`. If the guard only checks `!pendingNavigation`, it triggers while navigation highlight is still active. **Fix pattern:** Guard scroll restore with `!shouldDisableAutoScroll` instead of `!pendingNavigation`. The controller's `shouldDisableAutoScroll` covers the FULL lifecycle (pending → complete), not just while `pendingNavigation` exists. **Additional:** Save scroll position when `shouldDisableAutoScroll` transitions true→false (navigation completed) to prevent stale `savedScrollTop` from being restored later. ```typescript // ChatHistory.tsx — scroll restore effect useEffect(() => { const wasDisabled = prevShouldDisableRef.current; prevShouldDisableRef.current = shouldDisableAutoScroll; // Navigation just completed — save current position, skip restore if (wasDisabled && !shouldDisableAutoScroll && scrollContainerRef.current) { saveScrollPosition(scrollContainerRef.current.scrollTop); return; } if (isThisTabActive && savedScrollTop !== undefined && !conversationLoading && !shouldDisableAutoScroll) { // ... restore logic } }, [isThisTabActive, savedScrollTop, conversationLoading, shouldDisableAutoScroll, saveScrollPosition]); ``` ### 2. Redundant `fetchSessionDetail` Unmounts ChatHistory **Symptom:** Navigation doesn't scroll at all, or session "reloads" unnecessarily. **Root cause:** `navigateToSession` or `navigateToError` calls `fetchSessionDetail` even when the session is already loaded in an existing tab. This sets `conversationLoading: true`, causing ChatHistory to unmount (show loading spinner) and remount — losing scroll container and controller state. **Fix pattern:** Only call `fetchSessionDetail` for NEW tabs. For existing tabs, `setActiveTab` already handles the fetch when `sessionChanged` is true. ```typescript // tabSlice.ts — navigateToSession if (existingTab) { state.setActiveTab(existingTab.id); // NO fetchSessionDetail — setActiveTab handles it } else { state.openTab({ ... }); void state.fetchSessionDetail(projectId, sessionId); // Only for new tabs } ``` ### 3. Highlight Not Showing (Strict Post-Scroll Gates) **Symptom:** Scrolls to correct location but no red/yellow highlight ring appears. **Root cause:** `executeErrorNavigation` / `executeSearchNavigation` returns `false` after scroll due to strict gates: - `userInterrupted` — any accidental wheel/touch event during smooth scroll - `isElementVisibleInContainer` — element partially off-screen after centering (tall elements) - Element not found within 600ms timeout When `success = false`, `executeNavigation` clears all highlight state (`setHighlightedGroupId(null)`). **Fix pattern:** Set highlight BEFORE scroll attempt. Make scroll best-effort. Always return `true` once target group is found. ```typescript // In executeErrorNavigation: // 1. Find target group // 2. Expand group // 3. SET HIGHLIGHT HERE (before scroll) setHighlightedGroupId(targetGroupId); setIsSearchHighlight(false); if (toolUseId) setCurrentToolUseId(toolUseId); // 4. Best-effort scroll (don't gate highlight on scroll outcome) // 5. Return true (highlight already visible) ``` ### 4. Test Trigger Shows No Highlight **Symptom:** "Test trigger" creates an error with a timestamp that doesn't match any AI group. Navigation scrolls but nothing is highlighted. **Root cause:** Same as #3. The error timestamp doesn't match, so `findAIGroupByTimestamp` falls back to closest/last group. But post-scroll gates prevent the highlight from being applied. **Fix:** Same as #3 — highlight-first pattern. ### 5. `conversationLoading` Race During Tab Switch **Symptom:** Navigation queued on tab, but when switching to that tab, loading state causes ChatHistory unmount. Navigation controller state is lost. **Root cause:** `fetchSessionDetail` immediately sets `conversationLoading: true`. ChatHistory returns ``, unmounting the controller hook. **Recovery:** The controller is designed to survive remount — when ChatHistory remounts with `pendingNavigation` still set and `conversationLoading: false`, the detection effect starts fresh navigation. BUT scroll restore can race with it (see #1). ## Debugging Checklist When navigation isn't working: 1. **Check `pendingNavigation` exists on the tab** — is `enqueueTabNavigation` called? 2. **Check `isActiveTab` is true** — controller ignores inactive tabs 3. **Check `conversationLoading`** — if true, controller waits in `pending` phase 4. **Check `conversation` exists** — if null, controller waits 5. **Check timestamp matching** — does `findAIGroupByTimestamp` find the right group? 6. **Check element refs** — are `aiGroupRefs` / `chatItemRefs` populated? 7. **Check `shouldDisableAutoScroll`** — is scroll restore racing with navigation? 8. **Check for double `fetchSessionDetail`** — is ChatHistory unmounting unnecessarily? 9. **Check `phase` progression** — is it stuck in `pending` or failing at `scrolling`? ## Navigation Helper Functions (navigation/utils.ts) | Function | Purpose | |----------|---------| | `findAIGroupByTimestamp(items, timestamp)` | Find AI group containing/closest to timestamp | | `findChatItemByTimestamp(items, timestamp)` | Find any chat item by timestamp | | `findAIGroupBySubagentId(items, subagentId)` | Find AI group by subagent ID | | `calculateCenteredScrollTop(element, container, offset)` | Calculate scroll position to center element | | `waitForElementStability(element, timeout, stableFrames)` | Wait for element size to stop changing | | `waitForScrollEnd(container, timeout)` | Wait for smooth scroll to finish | | `isElementVisibleInContainer(element, container, offset)` | Check if element is in viewport | | `findCurrentSearchResultInContainer(container)` | Find `[data-search-result="current"]` element | ## Factory Helpers (tabs.ts) ```typescript createErrorNavigationRequest({ errorId, errorTimestamp, toolUseId, lineNumber }) createSearchNavigationRequest({ query, messageTimestamp, matchedText }) isErrorPayload(request) // type guard isSearchPayload(request) // type guard ``` ## Tests Related test files: - `test/renderer/store/tabSlice.test.ts` — `enqueueTabNavigation` / `consumeTabNavigation` - `test/renderer/store/notificationSlice.test.ts` — `navigateToError` behavior - `test/renderer/hooks/navigationUtils.test.ts` — Navigation utility functions - `test/renderer/hooks/useSearchContextNavigation.test.ts` — Search result finding Test patterns: ```typescript // Mock crypto for predictable nonces vi.stubGlobal('crypto', { randomUUID: () => `test-uuid-${++counter}` }); // Verify navigation request shape expect(tab.pendingNavigation?.kind).toBe('error'); expect(tab.pendingNavigation?.highlight).toBe('red'); // Verify nonce uniqueness on repeated clicks store.getState().navigateToError(error); const firstId = store.getState().openTabs[0].pendingNavigation?.id; store.getState().navigateToError(error); const secondId = store.getState().openTabs[0].pendingNavigation?.id; expect(firstId).not.toBe(secondId); ```