@@ -7,6 +7,31 @@ import {
77import { TAB_ID } from '@/stores/tabIdentity' ;
88
99const SUCCESS_HREF = '/favicon-success.png' ;
10+ const GENERATING_HREF = '/favicon-generating.png' ;
11+
12+ /** Visual favicon state derived from the local-generation lifecycle. */
13+ export type FaviconVisualState = 'default' | 'generating' | 'success' ;
14+
15+ /**
16+ * Pure mapping from the local-generation lifecycle to a favicon visual state.
17+ *
18+ * `armed` means this tab owns an in-flight or just-finished generation. While
19+ * that owned generation is in flight → `generating`; once it finishes →
20+ * `success`; anything else (not armed, or reset) → `default`. Extracted as a
21+ * pure function so the state machine is unit-testable without a DOM.
22+ */
23+ export const getFaviconVisualState = ( {
24+ isLoading,
25+ armed,
26+ } : {
27+ isLoading : boolean ;
28+ armed : boolean ;
29+ } ) : FaviconVisualState => {
30+ if ( ! armed ) {
31+ return 'default' ;
32+ }
33+ return isLoading ? 'generating' : 'success' ;
34+ } ;
1035
1136/**
1237 * Whether the in-flight generation for `sessionId` belongs to *this* tab.
@@ -38,20 +63,24 @@ interface UseAppFaviconProps {
3863}
3964
4065/**
41- * Switches the page favicon to a "success" variant when a generation that
42- * *this* tab started finishes, then resets it once the user returns to the
43- * tab (or switches sessions, or starts a new generation). The armed flag is
44- * kept in a ref (not React state) so it survives renders without retriggering
45- * effects.
66+ * Drives a three-state favicon: `default` → `generating` → `success`.
4667 *
47- * The favicon <link id="favicon"> is taken as the source of truth for the
48- * default href (captured on mount) so dev/production base paths are respected
49- * rather than hard-coding "/favicon.png".
68+ * When a generation *this* tab started is in flight, the favicon switches to
69+ * the generating variant; when it finishes, to the success variant; any reset
70+ * trigger (returning to the tab, switching sessions, a new generation starting)
71+ * clears back to default. Switching back to a session this tab is still
72+ * generating for re-arms the generating variant.
73+ *
74+ * The armed flag lives in a ref (not React state) so it survives renders
75+ * without retriggering effects. The favicon `<link id="favicon">` is the source
76+ * of truth for the default href (captured on mount) so dev/production base
77+ * paths are respected rather than hard-coding "/favicon.png".
5078 */
5179export const useAppFavicon = ( { isLoading, activeSessionId } : UseAppFaviconProps ) => {
5280 const defaultHrefRef = useRef < string | null > ( null ) ;
5381 const armedRef = useRef ( false ) ;
5482 const prevLoadingRef = useRef ( false ) ;
83+ const prevSessionIdRef = useRef < string | null > ( null ) ;
5584
5685 // Cache the default href once on mount. Everything resets to this value.
5786 useEffect ( ( ) => {
@@ -74,34 +103,54 @@ export const useAppFavicon = ({ isLoading, activeSessionId }: UseAppFaviconProps
74103 link . href = href ;
75104 } ;
76105
106+ const hrefForState = ( state : FaviconVisualState ) : string | null => {
107+ switch ( state ) {
108+ case 'generating' :
109+ return GENERATING_HREF ;
110+ case 'success' :
111+ return SUCCESS_HREF ;
112+ default :
113+ return defaultHrefRef . current ;
114+ }
115+ } ;
116+
77117 const resetToFavicon = useCallback ( ( ) => {
78118 armedRef . current = false ;
79119 setFaviconHref ( defaultHrefRef . current ) ;
80120 } , [ ] ) ;
81121
82- // arm/done: track the local generation lifecycle. The lease is only valid
83- // at the moment isLoading flips to true (released before the false flip),
84- // so capture ownership here, not on completion .
122+ // Lifecycle + session handling, unified in one effect so there is no ordering
123+ // race between a session reset and the isLoading edge that re-arms a
124+ // still-generating session .
85125 useEffect ( ( ) => {
86126 const wasLoading = prevLoadingRef . current ;
127+ const sessionChanged = prevSessionIdRef . current !== activeSessionId ;
87128
88- if ( isLoading && ! wasLoading ) {
89- // New generation starting. Reset any prior armed/done state first so a
90- // previous success favicon doesn't persist into the next turn, then arm
91- // if this tab owns the lease.
92- setFaviconHref ( defaultHrefRef . current ) ;
93- armedRef . current = isLocalGeneration ( activeSessionId ) ;
129+ if ( sessionChanged ) {
130+ // (Re)establish state for the session we just landed on. If this tab owns
131+ // an in-flight generation here, arm and show generating immediately; the
132+ // lease is only consultable while the generation is still running.
133+ armedRef . current = isLoading ? isLocalGeneration ( activeSessionId ) : false ;
134+ prevLoadingRef . current = isLoading ;
135+ prevSessionIdRef . current = activeSessionId ;
136+ setFaviconHref ( hrefForState ( getFaviconVisualState ( { isLoading, armed : armedRef . current } ) ) ) ;
137+ return ;
94138 }
95139
96- if ( ! isLoading && wasLoading && armedRef . current ) {
97- setFaviconHref ( SUCCESS_HREF ) ;
98- // armed stays true so the success variant persists until a reset trigger.
140+ // Same session: drive the favicon off isLoading edges only. Ownership is
141+ // only knowable at the moment a generation starts (the lease is released
142+ // before isLoading flips back to false), so (re)arm on the rising edge.
143+ if ( isLoading && ! wasLoading ) {
144+ armedRef . current = isLocalGeneration ( activeSessionId ) ;
145+ setFaviconHref ( hrefForState ( getFaviconVisualState ( { isLoading : true , armed : armedRef . current } ) ) ) ;
146+ } else if ( ! isLoading && wasLoading && armedRef . current ) {
147+ setFaviconHref ( hrefForState ( getFaviconVisualState ( { isLoading : false , armed : true } ) ) ) ;
99148 }
100-
101149 prevLoadingRef . current = isLoading ;
102150 } , [ isLoading , activeSessionId ] ) ;
103151
104- // Reset trigger 1: user returns to the tab.
152+ // Reset trigger: user returns to the tab. (Session changes and new
153+ // generations are handled by the lifecycle effect above.)
105154 useEffect ( ( ) => {
106155 const handleVisibilityChange = ( ) => {
107156 if ( ! document . hidden ) {
@@ -111,13 +160,4 @@ export const useAppFavicon = ({ isLoading, activeSessionId }: UseAppFaviconProps
111160 document . addEventListener ( 'visibilitychange' , handleVisibilityChange ) ;
112161 return ( ) => document . removeEventListener ( 'visibilitychange' , handleVisibilityChange ) ;
113162 } , [ resetToFavicon ] ) ;
114-
115- // Reset trigger 2: switching sessions clears any armed/done state.
116- useEffect ( ( ) => {
117- resetToFavicon ( ) ;
118- // We intentionally re-run when the session id changes; prevLoadingRef is
119- // reset too so the new session's first isLoading edge is treated as a
120- // fresh arm, not a completion.
121- prevLoadingRef . current = false ;
122- } , [ activeSessionId , resetToFavicon ] ) ;
123163} ;
0 commit comments