10 KiB
| name | description |
|---|---|
| ccc:navigation-scroll | 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)
// 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
!isActiveTabto prevent cross-tab races - Nonce dedup:
activeRequestIdRef.current === pendingNavigation.idprevents 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.
// 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.
// 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 scrollisElementVisibleInContainer— 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.
// 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 <ChatHistoryLoadingState />, 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:
- Check
pendingNavigationexists on the tab — isenqueueTabNavigationcalled? - Check
isActiveTabis true — controller ignores inactive tabs - Check
conversationLoading— if true, controller waits inpendingphase - Check
conversationexists — if null, controller waits - Check timestamp matching — does
findAIGroupByTimestampfind the right group? - Check element refs — are
aiGroupRefs/chatItemRefspopulated? - Check
shouldDisableAutoScroll— is scroll restore racing with navigation? - Check for double
fetchSessionDetail— is ChatHistory unmounting unnecessarily? - Check
phaseprogression — is it stuck inpendingor failing atscrolling?
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)
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/consumeTabNavigationtest/renderer/store/notificationSlice.test.ts—navigateToErrorbehaviortest/renderer/hooks/navigationUtils.test.ts— Navigation utility functionstest/renderer/hooks/useSearchContextNavigation.test.ts— Search result finding
Test patterns:
// 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);