Skip to content

Commit 4fd41ff

Browse files
yeahhe365claude
andcommitted
feat(favicon): add generating third state
Insert a "generating" favicon state between default and success so the page favicon reflects an in-flight local generation, not just its completion. - public/favicon-generating.png: new 64x64 asset, logo unchanged with a blue progress-ring badge (matching the success badge geometry) - useAppFavicon: add GENERATING_HREF and a pure getFaviconVisualState() helper; local generation now lights generating immediately on the rising isLoading edge, completes to success, and re-arms when switching back to a session this tab is still generating - tests: cover the new generating flow, the pure state mapper, and the re-arm-on-session-return case (15 passing) - .gitignore: exclude .env.bak (contains secrets) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ca7d176 commit 4fd41ff

4 files changed

Lines changed: 121 additions & 39 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ build/
99
.env
1010
.env.local
1111
.env.*.local
12+
.env.bak
1213

1314
# Logs
1415
*.log

public/favicon-generating.png

3.86 KB
Loading

src/hooks/app/useAppFavicon.test.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ vi.mock('@/features/message-sender/generationLease', () => ({
1818
isGenerationLeaseFresh: mockIsGenerationLeaseFresh,
1919
}));
2020

21-
import { useAppFavicon, isLocalGeneration } from './useAppFavicon';
21+
import { getFaviconVisualState, useAppFavicon, isLocalGeneration } from './useAppFavicon';
2222

2323
const DEFAULT_HREF = 'http://localhost/favicon.png';
24+
const GENERATING_HREF = 'http://localhost/favicon-generating.png';
2425
const SUCCESS_HREF = 'http://localhost/favicon-success.png';
2526

2627
const setFaviconLink = (href = DEFAULT_HREF) => {
@@ -64,6 +65,21 @@ const render = (initial: Props) => {
6465
return { rerender };
6566
};
6667

68+
describe('getFaviconVisualState', () => {
69+
it('returns generating when armed and loading', () => {
70+
expect(getFaviconVisualState({ isLoading: true, armed: true })).toBe('generating');
71+
});
72+
73+
it('returns success when armed and done loading', () => {
74+
expect(getFaviconVisualState({ isLoading: false, armed: true })).toBe('success');
75+
});
76+
77+
it('returns default when not armed, regardless of loading', () => {
78+
expect(getFaviconVisualState({ isLoading: true, armed: false })).toBe('default');
79+
expect(getFaviconVisualState({ isLoading: false, armed: false })).toBe('default');
80+
});
81+
});
82+
6783
describe('useAppFavicon', () => {
6884
beforeEach(() => {
6985
vi.clearAllMocks();
@@ -77,24 +93,32 @@ describe('useAppFavicon', () => {
7793
vi.restoreAllMocks();
7894
});
7995

80-
it('switches to the success variant when a locally-owned generation finishes', () => {
96+
it('shows generating while a locally-owned generation is in flight, then success on completion', () => {
8197
setLease('test-tab');
8298
const { rerender } = render({ isLoading: false, activeSessionId: 'session-1' });
8399

84100
rerender({ isLoading: true, activeSessionId: 'session-1' });
85-
expect(faviconHref()).toBe(DEFAULT_HREF);
101+
expect(faviconHref()).toBe(GENERATING_HREF);
86102

87103
rerender({ isLoading: false, activeSessionId: 'session-1' });
88104
expect(faviconHref()).toBe(SUCCESS_HREF);
89105
});
90106

107+
it('shows the generating variant immediately when mounted mid-generation this tab owns', () => {
108+
setLease('test-tab');
109+
render({ isLoading: true, activeSessionId: 'session-1' });
110+
111+
expect(faviconHref()).toBe(GENERATING_HREF);
112+
});
113+
91114
it('does not change the favicon for a generation owned by another tab', () => {
92115
setLease('other-tab');
93116
const { rerender } = render({ isLoading: false, activeSessionId: 'session-1' });
94117

95118
rerender({ isLoading: true, activeSessionId: 'session-1' });
96-
rerender({ isLoading: false, activeSessionId: 'session-1' });
119+
expect(faviconHref()).toBe(DEFAULT_HREF);
97120

121+
rerender({ isLoading: false, activeSessionId: 'session-1' });
98122
expect(faviconHref()).toBe(DEFAULT_HREF);
99123
});
100124

@@ -127,22 +151,39 @@ describe('useAppFavicon', () => {
127151
expect(faviconHref()).toBe(DEFAULT_HREF);
128152
});
129153

130-
it('resets before arming a new generation, then re-applies success on completion', () => {
154+
it('switches to generating, then back to success across generation cycles', () => {
131155
setLease('test-tab');
132156
const { rerender } = render({ isLoading: false, activeSessionId: 'session-1' });
133157
rerender({ isLoading: true, activeSessionId: 'session-1' });
134158
rerender({ isLoading: false, activeSessionId: 'session-1' });
135159
expect(faviconHref()).toBe(SUCCESS_HREF);
136160

137-
// New generation starts: must reset to default first, then arm.
161+
// A new generation replaces success with generating first, then completes to
162+
// success again.
138163
rerender({ isLoading: true, activeSessionId: 'session-1' });
139-
expect(faviconHref()).toBe(DEFAULT_HREF);
164+
expect(faviconHref()).toBe(GENERATING_HREF);
140165

141-
// And complete again → success.
142166
rerender({ isLoading: false, activeSessionId: 'session-1' });
143167
expect(faviconHref()).toBe(SUCCESS_HREF);
144168
});
145169

170+
it('re-arms the generating variant when switching back to a session this tab is still generating', () => {
171+
setLease('test-tab');
172+
const { rerender } = render({ isLoading: true, activeSessionId: 'session-1' });
173+
expect(faviconHref()).toBe(GENERATING_HREF);
174+
175+
// Switch away to a session this tab is not generating → default.
176+
clearLease();
177+
rerender({ isLoading: true, activeSessionId: 'session-2' });
178+
expect(faviconHref()).toBe(DEFAULT_HREF);
179+
180+
// Switch back to the still-generating session this tab owns → generating
181+
// is restored.
182+
setLease('test-tab');
183+
rerender({ isLoading: true, activeSessionId: 'session-1' });
184+
expect(faviconHref()).toBe(GENERATING_HREF);
185+
});
186+
146187
it('writes the href only once for repeated done transitions (idempotent)', () => {
147188
setLease('test-tab');
148189
const { rerender } = render({ isLoading: false, activeSessionId: 'session-1' });

src/hooks/app/useAppFavicon.ts

Lines changed: 71 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,31 @@ import {
77
import { TAB_ID } from '@/stores/tabIdentity';
88

99
const 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
*/
5179
export 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

Comments
 (0)